From 859cf100dd1520109ff4970d771096d33d7a172e Mon Sep 17 00:00:00 2001 From: shiftz300 Date: Fri, 2 Oct 2026 04:27:43 +0800 Subject: [PATCH] Refactor website with Vue 3, Vite and Vue Router Consolidate page views and shared controls, preserve astronomy interactions, add static prerendering and SEO metadata, and document the Bun development workflow. --- .github/SECURITY_HEADERS.md | 6 +- .gitignore | 3 + README.md | 66 + assets/textures/README.md | 22 - bun.lock | 143 +++ docs/textures.md | 21 + docs/venus.md | 12 + home.css | 1141 ----------------- index.html | 196 +-- llms.txt | 7 - mars-preview.js | 24 - mars/site.js | 3 - music.js | 77 -- package.json | 23 + page-init.js | 1 - _headers => public/_headers | 0 .../data/earth}/earth_atmos_2048.data.js | 0 .../data/mars}/mars-texture.js | 0 .../moons => public/data/mars}/moon-assets.js | 0 public/llms.txt | 7 + .../vendor => public/vendor/mars}/LICENSE.txt | 0 .../vendor/mars}/OrbitControls.js | 0 {mars/vendor => public/vendor/mars}/three.js | 0 {vendor => public/vendor}/three/LICENSE | 0 {vendor => public/vendor}/three/README.md | 0 .../vendor}/three/build/three.min.js | 0 .../jsm/controls/OrbitControls.global.js | 0 .../examples/jsm/controls/OrbitControls.js | 0 scripts/build-home-css.cjs | 7 - scripts/check-build.js | 97 ++ scripts/prerender.js | 64 + src/App.vue | 7 + ad astra.mp3 => src/assets/audio/ad-astra.mp3 | Bin {mars => src/assets/audio}/soil-of-life.mp3 | Bin .../assets/fonts/AVGARDDO_2.woff2 | Bin .../assets/fonts/ITCAvantGardePro-Bold.woff2 | Bin .../assets/fonts/ITCAvantGardePro-Book.woff2 | Bin .../assets/fonts/ITCAvantGardePro-Demi.woff2 | Bin .../assets/images/home/avatar-560.webp | Bin .../assets/images/home/backdrop-3.webp | Bin .../assets/images/mars}/jezero.jpg | Bin .../assets/images/mars}/landform-craters.jpg | Bin .../images/mars}/landform-ice-clouds.png | Bin .../assets/images/mars}/landform-tharsis.jpg | Bin .../assets/images/mars}/landform-water.jpg | Bin .../assets/images/mars}/landform-wind.jpg | Bin .../assets/images/mars}/mars-texture.jpg | Bin .../assets/images/mars}/olympus.jpg | Bin .../assets/images/mars}/polar.jpg | Bin .../images/mars}/rovers/curiosity-concept.jpg | Bin .../images/mars}/rovers/curiosity-surface.jpg | Bin .../assets/images/mars}/rovers/curiosity.jpg | Bin .../mars}/rovers/opportunity-concept.jpg | Bin .../mars}/rovers/opportunity-surface.jpg | Bin .../images/mars}/rovers/opportunity.jpg | Bin .../mars}/rovers/perseverance-concept.jpg | Bin .../mars}/rovers/perseverance-surface.jpg | Bin .../images/mars}/rovers/perseverance.jpg | Bin .../images/mars}/rovers/spirit-concept.jpg | Bin .../images/mars}/rovers/spirit-overview.jpg | Bin .../images/mars}/rovers/spirit-surface.jpg | Bin .../assets/images/mars}/rovers/spirit.jpg | Bin .../images/mars}/rovers/zhurong-concept.png | Bin .../images/mars}/rovers/zhurong-surface.jpg | Bin .../assets/images/mars}/rovers/zhurong.jpg | Bin .../assets/images/mars}/valles.jpg | Bin .../images}/textures/earth_atmos_2048.jpg | Bin .../assets/images/venus}/milky-way-2mass.jpg | Bin .../venus}/venus-atmosphere-profile.png | Bin .../images/venus}/venus-ishtar-overhead.jpg | Bin .../images/venus}/venus-ishtar-terra.jpg | Bin .../assets/images/venus}/venus-ovda-regio.jpg | Bin .../assets/images/venus}/venus-surface.jpg | Bin .../images/venus}/venus-user-maat-mons.png | Bin .../images/venus}/venus-venera13-color.jpg | Bin .../venus}/venus-venera14-remapped-color.png | Bin .../images/venus}/venus-volcanic-plains.jpg | Bin .../images/venus}/venus-yavine-corona.jpg | Bin style.css => src/assets/main.css | 14 +- .../assets/styles/home/mars-entry.css | 0 .../assets/styles/home/navigation.css | 0 .../assets/styles/home/venus-entry.css | 0 {mars => src/assets/styles/mars}/mars.css | 8 +- {mars => src/assets/styles/mars}/mobile.css | 8 +- {mars => src/assets/styles/mars}/moons.css | 17 +- {mars => src/assets/styles/mars}/pioneer.css | 0 {mars => src/assets/styles/mars}/rovers.css | 0 {mars => src/assets/styles/mars}/site.css | 0 {mars => src/assets/styles/mars}/sky.css | 2 +- .../assets/styles/terminal}/terminal.css | 0 {venus => src/assets/styles/venus}/site.css | 0 {venus => src/assets/styles/venus}/venus.css | 2 +- src/components/BackIcon.vue | 3 + src/components/MusicButton.vue | 8 + src/components/MusicPlayer.vue | 87 ++ src/composables/usePageScope.js | 92 ++ src/composables/usePlanetPreview.js | 38 + src/main.js | 12 + src/router/index.js | 37 + app.js => src/scripts/home/app.js | 47 +- .../scripts/home/navigation.js | 37 +- {mars => src/scripts/mars}/moons.js | 15 +- {mars => src/scripts/mars}/music.js | 56 +- {mars => src/scripts/mars}/pioneer.js | 5 +- {mars => src/scripts/mars}/planet.js | 28 +- {mars => src/scripts/mars}/rover-data.js | 7 +- {mars => src/scripts/mars}/rovers.js | 52 +- {mars => src/scripts/mars}/simulation.js | 15 +- {mars => src/scripts/mars}/sky-model.js | 6 +- {mars => src/scripts/mars}/sky.js | 7 +- .../scripts/terminal}/terminal.js | 86 +- {venus => src/scripts/venus}/model.js | 54 +- {venus => src/scripts/venus}/venus.js | 19 +- src/seo.js | 102 ++ src/utils/assets.js | 16 + src/utils/engine.js | 36 + .../utils/idle-controls.js | 19 +- src/views/Home.vue | 199 +++ mars/index.html => src/views/Mars.vue | 117 +- .../index.html => src/views/Terminal.vue | 54 +- venus/index.html => src/views/Venus.vue | 85 +- venus-preview.js | 28 - venus/README.md | 12 - venus/page.js | 4 - vite.config.js | 56 + 125 files changed, 1557 insertions(+), 1860 deletions(-) create mode 100644 .gitignore create mode 100644 README.md delete mode 100644 assets/textures/README.md create mode 100644 bun.lock create mode 100644 docs/textures.md create mode 100644 docs/venus.md delete mode 100644 home.css delete mode 100644 llms.txt delete mode 100644 mars-preview.js delete mode 100644 mars/site.js delete mode 100644 music.js create mode 100644 package.json delete mode 100644 page-init.js rename _headers => public/_headers (100%) rename {assets/textures => public/data/earth}/earth_atmos_2048.data.js (100%) rename {mars/assets => public/data/mars}/mars-texture.js (100%) rename {mars/assets/moons => public/data/mars}/moon-assets.js (100%) create mode 100644 public/llms.txt rename {mars/vendor => public/vendor/mars}/LICENSE.txt (100%) rename {mars/vendor => public/vendor/mars}/OrbitControls.js (100%) rename {mars/vendor => public/vendor/mars}/three.js (100%) rename {vendor => public/vendor}/three/LICENSE (100%) rename {vendor => public/vendor}/three/README.md (100%) rename {vendor => public/vendor}/three/build/three.min.js (100%) rename {vendor => public/vendor}/three/examples/jsm/controls/OrbitControls.global.js (100%) rename {vendor => public/vendor}/three/examples/jsm/controls/OrbitControls.js (100%) delete mode 100644 scripts/build-home-css.cjs create mode 100644 scripts/check-build.js create mode 100644 scripts/prerender.js create mode 100644 src/App.vue rename ad astra.mp3 => src/assets/audio/ad-astra.mp3 (100%) rename {mars => src/assets/audio}/soil-of-life.mp3 (100%) rename AVGARDDO_2.woff2 => src/assets/fonts/AVGARDDO_2.woff2 (100%) rename ITCAvantGardePro-Bold.woff2 => src/assets/fonts/ITCAvantGardePro-Bold.woff2 (100%) rename ITCAvantGardePro-Book.woff2 => src/assets/fonts/ITCAvantGardePro-Book.woff2 (100%) rename ITCAvantGardePro-Demi.woff2 => src/assets/fonts/ITCAvantGardePro-Demi.woff2 (100%) rename avatar-560.webp => src/assets/images/home/avatar-560.webp (100%) rename backdrop-3.webp => src/assets/images/home/backdrop-3.webp (100%) rename {mars/assets => src/assets/images/mars}/jezero.jpg (100%) rename {mars/assets => src/assets/images/mars}/landform-craters.jpg (100%) rename {mars/assets => src/assets/images/mars}/landform-ice-clouds.png (100%) rename {mars/assets => src/assets/images/mars}/landform-tharsis.jpg (100%) rename {mars/assets => src/assets/images/mars}/landform-water.jpg (100%) rename {mars/assets => src/assets/images/mars}/landform-wind.jpg (100%) rename {mars/assets => src/assets/images/mars}/mars-texture.jpg (100%) rename {mars/assets => src/assets/images/mars}/olympus.jpg (100%) rename {mars/assets => src/assets/images/mars}/polar.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/curiosity-concept.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/curiosity-surface.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/curiosity.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/opportunity-concept.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/opportunity-surface.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/opportunity.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/perseverance-concept.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/perseverance-surface.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/perseverance.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/spirit-concept.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/spirit-overview.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/spirit-surface.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/spirit.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/zhurong-concept.png (100%) rename {mars/assets => src/assets/images/mars}/rovers/zhurong-surface.jpg (100%) rename {mars/assets => src/assets/images/mars}/rovers/zhurong.jpg (100%) rename {mars/assets => src/assets/images/mars}/valles.jpg (100%) rename {assets => src/assets/images}/textures/earth_atmos_2048.jpg (100%) rename {venus => src/assets/images/venus}/milky-way-2mass.jpg (100%) rename {venus => src/assets/images/venus}/venus-atmosphere-profile.png (100%) rename {venus => src/assets/images/venus}/venus-ishtar-overhead.jpg (100%) rename {venus => src/assets/images/venus}/venus-ishtar-terra.jpg (100%) rename {venus => src/assets/images/venus}/venus-ovda-regio.jpg (100%) rename {venus => src/assets/images/venus}/venus-surface.jpg (100%) rename {venus => src/assets/images/venus}/venus-user-maat-mons.png (100%) rename {venus => src/assets/images/venus}/venus-venera13-color.jpg (100%) rename {venus => src/assets/images/venus}/venus-venera14-remapped-color.png (100%) rename {venus => src/assets/images/venus}/venus-volcanic-plains.jpg (100%) rename {venus => src/assets/images/venus}/venus-yavine-corona.jpg (100%) rename style.css => src/assets/main.css (97%) rename mars-entry.css => src/assets/styles/home/mars-entry.css (100%) rename navigation.css => src/assets/styles/home/navigation.css (100%) rename venus-entry.css => src/assets/styles/home/venus-entry.css (100%) rename {mars => src/assets/styles/mars}/mars.css (99%) rename {mars => src/assets/styles/mars}/mobile.css (93%) rename {mars => src/assets/styles/mars}/moons.css (75%) rename {mars => src/assets/styles/mars}/pioneer.css (100%) rename {mars => src/assets/styles/mars}/rovers.css (100%) rename {mars => src/assets/styles/mars}/site.css (100%) rename {mars => src/assets/styles/mars}/sky.css (97%) rename {delocalized configuration project => src/assets/styles/terminal}/terminal.css (100%) rename {venus => src/assets/styles/venus}/site.css (100%) rename {venus => src/assets/styles/venus}/venus.css (99%) create mode 100644 src/components/BackIcon.vue create mode 100644 src/components/MusicButton.vue create mode 100644 src/components/MusicPlayer.vue create mode 100644 src/composables/usePageScope.js create mode 100644 src/composables/usePlanetPreview.js create mode 100644 src/main.js create mode 100644 src/router/index.js rename app.js => src/scripts/home/app.js (86%) rename navigation.js => src/scripts/home/navigation.js (78%) rename {mars => src/scripts/mars}/moons.js (93%) rename {mars => src/scripts/mars}/music.js (77%) rename {mars => src/scripts/mars}/pioneer.js (88%) rename {mars => src/scripts/mars}/planet.js (89%) rename {mars => src/scripts/mars}/rover-data.js (99%) rename {mars => src/scripts/mars}/rovers.js (87%) rename {mars => src/scripts/mars}/simulation.js (94%) rename {mars => src/scripts/mars}/sky-model.js (96%) rename {mars => src/scripts/mars}/sky.js (96%) rename {delocalized configuration project => src/scripts/terminal}/terminal.js (91%) rename {venus => src/scripts/venus}/model.js (83%) rename {venus => src/scripts/venus}/venus.js (90%) create mode 100644 src/seo.js create mode 100644 src/utils/assets.js create mode 100644 src/utils/engine.js rename idle-controls.js => src/utils/idle-controls.js (63%) create mode 100644 src/views/Home.vue rename mars/index.html => src/views/Mars.vue (72%) rename delocalized configuration project/index.html => src/views/Terminal.vue (86%) rename venus/index.html => src/views/Venus.vue (77%) delete mode 100644 venus-preview.js delete mode 100644 venus/README.md delete mode 100644 venus/page.js create mode 100644 vite.config.js diff --git a/.github/SECURITY_HEADERS.md b/.github/SECURITY_HEADERS.md index a304125..67e8b07 100644 --- a/.github/SECURITY_HEADERS.md +++ b/.github/SECURITY_HEADERS.md @@ -1,6 +1,6 @@ # Deployment security headers -The repository root contains `_headers`, using the format supported by Cloudflare +`public/_headers` uses the format supported by Cloudflare Pages. It applies the Content Security Policy and related response headers to every path. @@ -10,8 +10,8 @@ so the deployment header remains the authoritative policy. ## Cloudflare Pages -No extra build step is required. Publish `_headers` with the rest of the site and -verify the deployed response with: +Run `bun install` and `bun run build`, then publish the complete `dist/` directory. +Vite copies `public/_headers` into `dist/_headers`. Verify the deployed response with: ```powershell curl.exe -I https://empty610.com/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bdd52e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d9be56 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# website + +Personal website and interactive astronomy projects by **empty610**. +Built with **Bun, Vue 3, Vite, and Vue Router**. Static hosting, no backend. + +## Quick start + +```sh +bun install --frozen-lockfile +bun run dev +``` + +| Command | Purpose | +| --- | --- | +| `bun run dev` | Start the local development server | +| `bun run build` | Bundle assets and prerender all four pages into `dist/` | +| `bun run check` | Validate built HTML, assets, links, SEO, sitemap, and CSP | +| `bun run preview` | Serve the production build locally | + +Run `build` before `check` or `preview`. + +## Structure + +```text +index.html Single HTML source entry +src/ + App.vue RouterView + main.js App setup and hydration + views/ Home.vue, Venus.vue, Mars.vue, Terminal.vue + components/ Shared back icon and music controls + router/index.js Routes and scroll behavior + seo.js Site URL, page metadata, and structured data + scripts/ Interactions and astronomy logic, grouped by page + assets/ Images, fonts, audio, and page styles + composables/ Page lifecycle cleanup and planet previews + utils/ Asset URLs, engine loading, and idle controls +public/ Fixed-path vendor files, model data, and hosting headers +scripts/ Prerendering and build validation +docs/ Project notes and asset sources +``` + +## Pages + +| Route | View | +| --- | --- | +| `/` | `Home.vue` | +| `/venus/` | `Venus.vue` | +| `/mars/` | `Mars.vue` | +| `/delocalized%20configuration%20project/` | `Terminal.vue` | + +Keep each page's content in its view; extract components only when shared. +Page scripts use `usePageScope()` to release listeners, timers, observers, audio, and WebGL resources on navigation. + +Store media in `src/assets/`; use `assetUrl()` for dynamic references. Vite generates hashed asset URLs. Use `public/` for files that require stable paths. +Page styles are scoped by `data-page`. Vue and JavaScript edits reload the page; CSS updates in place. + +To add a page, create its view, register metadata in `src/seo.js` and a route in `src/router/index.js`, then add it to `scripts/prerender.js`. Extend the page-style mapping in `vite.config.js` if it has its own style directory. + +## Deploy + +Run `bun run build` and publish the entire `dist/` directory at the domain root. +Each route gets a complete static `index.html`, so direct visits and refreshes work without a server renderer. Matching `/index.html` aliases remain supported. + +Update the production URL (`https://empty610.com/`) and page metadata in `src/seo.js`. Builds include canonical URLs, Open Graph / Twitter tags, JSON-LD, `robots.txt`, and `sitemap.xml`. Route changes update metadata in the browser; the homepage also displays its content without JavaScript. + +Production retains CSP and copies `public/_headers` for hosts that support it. Development and preview bind to localhost; development removes the CSP meta tag to support Vite updates. Rebuild after source changes; do not edit `dist/` directly. diff --git a/assets/textures/README.md b/assets/textures/README.md deleted file mode 100644 index 1c50037..0000000 --- a/assets/textures/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Texture provenance - -`earth_atmos_2048.jpg` is the Earth texture from the official Three.js `r160` -tag: - -`examples/textures/planets/earth_atmos_2048.jpg` - -Verification values: - -```text -Git blob SHA-1: 4275ab0836b482e4fbe1d5081976f3f2ffb41ffa -SHA-256: 2faa400d1eade3bc44caf6750d1b772bd9bbb6d87362c02bc7c3304e78fa0a9d -``` - -`earth_atmos_2048.data.js` is a generated data-URL copy of the same JPEG. It is -loaded only for direct `file://` previews because browsers do not allow a local -JPEG with an opaque file origin to be uploaded to WebGL. Deployed HTTP(S) pages -continue to load the smaller JPEG file. Its SHA-256 is: - -```text -039ef93549cb3a3c84f8753137768e0aa7f7dd957d277875ec6ab7006c1dc4af -``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5620a72 --- /dev/null +++ b/bun.lock @@ -0,0 +1,143 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "website", + "dependencies": { + "vue": "^3.5.43", + "vue-router": "4", + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.9", + "@vue/server-renderer": "^3.5.43", + "vite": "^8.3.2", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.9", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.43", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.43", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.43", "", { "dependencies": { "@vue/compiler-core": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.43", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.43", "@vue/compiler-dom": "3.5.43", "@vue/compiler-ssr": "3.5.43", "@vue/shared": "3.5.43", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.28", "source-map-js": "^1.2.1" } }, "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.43", "", { "dependencies": { "@vue/compiler-dom": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg=="], + + "@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.43", "", { "dependencies": { "@vue/shared": "3.5.43" } }, "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.43", "", { "dependencies": { "@vue/reactivity": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.43", "", { "dependencies": { "@vue/reactivity": "3.5.43", "@vue/runtime-core": "3.5.43", "@vue/shared": "3.5.43", "csstype": "^3.2.3" } }, "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.43", "", { "dependencies": { "@vue/compiler-ssr": "3.5.43", "@vue/runtime-dom": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ=="], + + "@vue/shared": ["@vue/shared@3.5.43", "", {}, "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], + + "vue": ["vue@3.5.43", "", { "dependencies": { "@vue/compiler-dom": "3.5.43", "@vue/compiler-sfc": "3.5.43", "@vue/runtime-dom": "3.5.43", "@vue/server-renderer": "3.5.43", "@vue/shared": "3.5.43" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng=="], + + "vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], + } +} diff --git a/docs/textures.md b/docs/textures.md new file mode 100644 index 0000000..e8f2669 --- /dev/null +++ b/docs/textures.md @@ -0,0 +1,21 @@ +# Texture provenance + +`src/assets/images/textures/earth_atmos_2048.jpg` is the Earth texture from the official Three.js `r160` +tag: + +`examples/textures/planets/earth_atmos_2048.jpg` + +Verification values: + +```text +Git blob SHA-1: 4275ab0836b482e4fbe1d5081976f3f2ffb41ffa +SHA-256: 2faa400d1eade3bc44caf6750d1b772bd9bbb6d87362c02bc7c3304e78fa0a9d +``` + +`public/data/earth/earth_atmos_2048.data.js` is an archived data-URL copy of the same JPEG, +originally used for direct `file://` previews. The Vue/Vite site is served over +HTTP(S) and loads the JPEG through Vite's imported asset URL. The archived script's SHA-256 is: + +```text +039ef93549cb3a3c84f8753137768e0aa7f7dd957d277875ec6ab7006c1dc4af +``` diff --git a/docs/venus.md b/docs/venus.md new file mode 100644 index 0000000..72581b0 --- /dev/null +++ b/docs/venus.md @@ -0,0 +1,12 @@ +# Venus + +Integrated from the user's `E:/solar system/venus/venus_v3.html` project. + +- Public entry: `/venus/`. BACK returns to `/#venus`. +- The home page includes a 90vh preview (100vh on phones) between DC and Future, with replayable scroll-entry fades; its renderer loads only near the viewport and pauses when hidden or navigating away. +- `src/scripts/venus/model.js` shares the original sphere, glow and texture between the preview and the interactive project. It uses Three.js r160 and OrbitControls from `public/vendor/three/`, with no CDN or iframe. +- `src/assets/images/venus/venus-surface.jpg` is the lossless extraction of the JPEG embedded in the original `venus-texture.js`; the other project images are copied unchanged. Vite emits hashed media URLs. +- `src/views/Venus.vue` contains the complete planet page. Astronomy scripts are in `src/scripts/venus/`, styles in `src/assets/styles/venus/`. The page starts and releases its renderer through Vue lifecycle hooks. +- `src/components/MusicPlayer.vue` carries the user's playback preference and position across pages. Browser autoplay restrictions may require one interaction to resume playback. + +Use `bun run dev` for development, or `bun run build` followed by `bun run preview` to check the static output. Deploy `dist/`; no backend is required. diff --git a/home.css b/home.css deleted file mode 100644 index 08259aa..0000000 --- a/home.css +++ /dev/null @@ -1,1141 +0,0 @@ -/* Generated by scripts/build-home-css.cjs */ -/* 本地的 ITC Avant Garde Gothic Pro 多字重 */ -@font-face { - font-family: "Avant Garde"; - src: url("ITCAvantGardePro-Book.woff2") format("woff2"); - font-weight: 300 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: "Avant Garde"; - src: url("ITCAvantGardePro-Demi.woff2") format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: "Avant Garde"; - src: url("ITCAvantGardePro-Bold.woff2") format("woff2"); - font-weight: 700 900; - font-style: normal; - font-display: swap; -} - -/* 斜体(可选,仅在用到 font-style: italic 时生效) */ -@font-face { - font-family: "Avant Garde"; - src: url("AVGARDDO_2.woff2") format("woff2"); - font-weight: 700; - font-style: italic; - font-display: swap; -} - -:root { - --board: #f5f0e7; /* warm cream background */ - --grid: rgba(0, 0, 0, 0.045); /* subtle grid lines */ - --ink: #14120e; /* near-black text */ - --ink-soft: rgba(20, 18, 14, 0.6); - --grid-size: 96px; - - /* 正文:保持原有风格,仍用 Helvetica 系 */ - --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Helvetica, Arial, sans-serif; - /* 标题:本地 Avant Garde 优先,缺失时使用系统字体。 */ - --font-display: "Avant Garde", "ITC Avant Garde Gothic Pro", "Avant Garde Gothic", - "Helvetica Neue", Helvetica, Arial, sans-serif; - - /* 参考图里的低饱和度三色装饰条(青 -> 金 -> 红) */ - --accent-gradient: linear-gradient(90deg, #6fd3c7 0%, #f2c14e 45%, #d24b45 100%); -} - -* { box-sizing: border-box; } - -html { scroll-behavior: smooth; } - -body { - margin: 0; - font-family: var(--font); - color: var(--ink); - overflow-x: hidden; - - /* 整站背景板:米色 + 网格,固定不随滚动移动 */ - background-color: var(--board); - background-image: - linear-gradient(var(--grid) 1px, transparent 1px), - linear-gradient(90deg, var(--grid) 1px, transparent 1px); - background-size: var(--grid-size) var(--grid-size); - background-attachment: fixed; -} - -/* 内容容器,铺满可滚动区域 */ -.board { min-height: 100vh; } - -/* ---------- Loading screen (fixed overlay) ---------- */ -.loading { - position: fixed; - inset: 0; - z-index: 20; - display: flex; - align-items: center; - justify-content: center; - transition: opacity 0.75s ease, transform 0.75s ease; -} - -.loading.is-done { - opacity: 0; - transform: scale(1.02); - pointer-events: none; -} - -.loading-inner { - display: flex; - flex-direction: column; - align-items: center; - gap: 2.75rem; - transform: translateY(4vh); -} - -/* Heavy headline with a soft mirrored reflection */ -.word { - margin: 0; - font-family: var(--font); - font-weight: 900; - font-size: clamp(2.75rem, 11vw, 6.5rem); - line-height: 1; - letter-spacing: -0.03em; - color: var(--ink); - display: block; -} - -.word-face, -.word-reflection { - display: block; - text-align: center; -} - -/* The reflection is a vertical mirror that fades out as it drops away. */ -.word-reflection { - margin-top: 0.06em; - transform: scaleY(-1); - transform-origin: center; - opacity: 0.3; - filter: blur(1.5px); - -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.22) 38%, rgba(0,0,0,0.5) 72%, rgba(0,0,0,1) 100%); - mask-image: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.22) 38%, rgba(0,0,0,0.5) 72%, rgba(0,0,0,1) 100%); - pointer-events: none; - user-select: none; -} - -/* ---------- Loading progress ---------- */ -.progress { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - width: min(320px, 72vw); -} - -.progress-track { - width: 100%; - height: 3px; - background: rgba(0, 0, 0, 0.12); - border-radius: 3px; - overflow: hidden; -} - -.progress-fill { - width: 0%; - height: 100%; - background: var(--ink); - border-radius: 3px; - transition: width 0.1s linear; -} - -.percent { - font-size: 0.85rem; - font-weight: 600; - letter-spacing: 0.08em; - color: var(--ink-soft); - font-variant-numeric: tabular-nums; -} - -/* ---------- Main screen (scrollable) ---------- */ -.main { - position: relative; - z-index: 1; - padding-bottom: 8vh; - opacity: 0; - /* Start the welcome reveal just after the loading screen begins fading. */ - transition: opacity 0.8s ease 0.2s; -} - -#app.is-ready .main { opacity: 1; } - -/* 音乐播放/暂停小按钮(固定右下角) */ -.music-toggle { - position: fixed; - right: 18px; - bottom: 18px; - z-index: 320; - width: 44px; - height: 44px; - border-radius: 12px; - border: 1px solid rgba(0, 0, 0, 0.10); - background: rgba(20, 18, 14, 0.55); - backdrop-filter: blur(6px); - color: #e9e5dc; - font-size: 20px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.25s ease, color 0.25s ease, transform 0.15s ease; -} - -.music-toggle:hover { - background: rgba(20, 18, 14, 0.75); - transform: translateY(-2px); -} - -.music-toggle:active { transform: translateY(0); } -html.music-playing .music-toggle { color: #d9b95a; border-color: rgba(217, 185, 90, 0.35); } -.music-toggle { transition: opacity .28s ease, background .25s ease, color .25s ease, transform .15s ease; } -.music-toggle.is-idle-hidden { opacity: 0; pointer-events: none; } - -/* 第三面入口与仪表盘返回键共用深色金边面板。 */ -.terminal-close, -.enter-btn { - text-decoration: none; - color: #f6f1e8; - background: rgba(13, 11, 8, 0.9); - backdrop-filter: blur(6px); - border: 1px solid rgba(217, 185, 90, 0.55); - box-shadow: 0 8px 26px rgba(0, 0, 0, 0.32); - transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease, opacity 0.28s ease, transform 0.28s ease; -} - -/* 终端覆盖层右上角的返回按钮 */ -.terminal-close { - position: fixed; - top: 16px; - right: 18px; - z-index: 99999; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - font-family: var(--font); - font-size: 13px; - font-weight: 500; - letter-spacing: 0.14em; - text-transform: uppercase; - white-space: nowrap; - min-width: 118px; - line-height: 1; - padding: 12px 16px; - border-radius: 999px; - cursor: pointer; -} - -.terminal-close.is-idle-hidden { - opacity: 0; - transform: translateY(-8px); - pointer-events: none; -} - -.back-u-turn { - width: 18px; - height: 18px; - fill: none; - stroke: currentColor; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.terminal-close:hover, -.enter-btn:hover { - background: #b89438; - color: #0d0b08; - border-color: #b89438; - box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3); -} - -/* 终端覆盖层出现时的优雅进入(保持不透明,避免浅色底透出;深色启动界面直接呈现出) */ -.terminal-overlay { - animation: terminal-in 0.4s ease; -} - -@keyframes terminal-in { - from { transform: scale(1.02); } - to { transform: none; } -} - -/* Scroll 渐变动画(约 0.5s)——只有启用 JS 时才先隐藏,避免无 JS 时内容缺失 */ -.js .reveal { - opacity: 0; - transform: translateY(26px); - transition: opacity 0.5s ease, transform 0.5s ease; -} - -.js .reveal.is-visible { - opacity: 1; - transform: none; -} - -/* 第三面:透明度由 JS 随滚动位置连续设置(无过渡,跟随滚动 1:1) */ -.js .reveal-fade { - opacity: 0; -} - -/* 首屏 WELCOME 随滚动离场时使用更舒缓的 3 秒淡出。 */ -.js .hero.reveal-fade { - transition: opacity 3s ease; -} - -/* 三色装饰条 */ -.accent-bar { - display: block; - height: 6px; - width: min(420px, 68vw); - border-radius: 999px; - background: var(--accent-gradient); - margin-top: 1.1rem; -} - -.accent-bar.small { - align-self: center; - flex: 1; - width: auto; - min-width: 3rem; - height: 4px; - margin: 0; -} - -/* ---------- Hero: WELCOME 欢迎 ---------- */ -.hero { - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.2em; - padding: 8vh 5vw; - text-align: center; -} - -.hero-title { - margin: 0; - display: flex; - flex-wrap: nowrap; - justify-content: center; - gap: 0.18em; - font-family: var(--font-display); - font-weight: 800; - font-size: clamp(2.3rem, 9.5vw, 8rem); - line-height: 1; - letter-spacing: -0.02em; -} - -/* ---------- Section: 关于我 ---------- */ -.section { - padding: 10vh 6vw; -} - -.section-head { - display: flex; - align-items: center; - gap: 0.9rem; - margin-bottom: 2.5rem; -} - -.section-icon { - width: 30px; - height: 30px; - flex: none; - border-radius: 50%; - border: 3px solid transparent; - /* 渐变描边环(借用三色渐变,米色填充内芯) */ - background: - linear-gradient(var(--board), var(--board)) padding-box, - var(--accent-gradient) border-box; -} - -.section-title { - margin: 0; - flex: none; - font-family: var(--font-display); - font-weight: 800; - letter-spacing: 0.01em; - font-size: clamp(1.6rem, 4vw, 2.6rem); -} - -.about-grid { - display: grid; - grid-template-columns: 1.4fr 1fr; - gap: clamp(2rem, 6vw, 4rem); - align-items: center; -} - -.about-text { - display: flex; - flex-direction: column; - gap: 1.3rem; -} - -.about-text p { - margin: 0; - font-family: var(--font); - font-size: 1.06rem; - line-height: 1.85; - color: var(--ink-soft); - text-align: justify; -} - -.about-photo { - margin: 0; - aspect-ratio: 1; - border-radius: 18px; - overflow: hidden; - border: 1px solid rgba(0, 0, 0, 0.08); - box-shadow: 0 24px 60px -30px rgba(0, 0, 0, 0.25); -} - -.about-photo .photo { - width: 100%; - height: 100%; - display: block; - object-fit: cover; - object-position: center; -} - -/* ---------- 联系区 ---------- */ -.contact-row { - display: flex; - flex-wrap: wrap; - gap: 18px; - justify-content: center; - align-items: center; - margin-top: 0.5rem; -} - -.contact-circle { - width: 58px; - height: 58px; - border-radius: 50%; - border: 1.5px solid rgba(184, 148, 56, 0.55); - color: #b89438; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 22px; - text-decoration: none; - transition: background 0.25s ease, color 0.25s ease, transform 0.18s ease, border-color 0.25s ease; -} - -.contact-circle svg { width: 24px; height: 24px; fill: currentColor; } -.contact-circle .gmail-icon { - width: 27px; - height: 27px; - fill: none; - stroke: currentColor; - stroke-width: 2.15; - stroke-linecap: round; - stroke-linejoin: round; -} -.contact-circle .glyph { line-height: 1; } - -.contact-circle:hover { - background: #b89438; - color: #0d0b08; - border-color: #b89438; - transform: translateY(-3px); -} - -/* ---------- 第四面:未来呢 ---------- */ -.future { - min-height: 70vh; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - padding: 14vh 6vw 10vh; -} - -.future-title { - margin: 0; - font-family: var(--font-display); - font-weight: 800; - font-size: clamp(3rem, 12vw, 9rem); - line-height: 1; - letter-spacing: -0.02em; -} - -.future-sub { - margin: 1.4rem 0 0; - font-family: var(--font); - font-size: clamp(1rem, 2.4vw, 1.4rem); - letter-spacing: 0.06em; - color: var(--ink-soft); -} - -/* ---------- 第三面:lo-fi 底片背景 ---------- */ -.section-3 { - position: relative; - min-height: 100vh; - min-height: 100svh; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - padding: clamp(72px, 12vh, 150px) 8vw; - margin: 0; - text-align: center; - background-image: url("backdrop-3.webp"); - background-size: cover; - background-position: center; - color: #e9e5dc; -} - -/* 中间淡淡的暗色渐晕,让压在彩色条上的文字更清晰 */ -.section-3::before { - content: ""; - position: absolute; - inset: 0; - background: radial-gradient(ellipse 62% 55% at 50% 48%, rgba(8, 8, 10, 0.42), rgba(8, 8, 10, 0) 76%); -} - -.section-3-content { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: clamp(28px, 4vh, 48px); -} - -.deloc { - display: flex; - flex-direction: column; - font-family: var(--font-display); /* Avant Garde */ - font-weight: 700; - font-size: clamp(1.7rem, 8vw, 6.5rem); - line-height: 1.1; - letter-spacing: -0.04em; - text-transform: uppercase; -} - -.deloc-sub { - margin: 0; - max-width: 48ch; - line-height: 1.8; - font-family: var(--font-display); - font-size: clamp(0.78rem, 1.6vw, 1.05rem); - letter-spacing: 0.16em; - text-transform: uppercase; - color: rgba(233, 229, 220, 0.85); -} - -.section-3 .enter-btn { margin-top: 12px; } - -/* 进入按钮:与仪表盘返回键一致的深色金边胶囊面板。 */ -.enter-btn { - display: inline-flex; - align-items: center; - gap: 10px; - margin-top: 3.2rem; - padding: 15px 30px; - border-radius: 999px; - font-family: var(--font); - font-size: 15px; - font-weight: 500; - letter-spacing: 0.02em; - text-decoration: none; -} - -.enter-btn .btn-arrow { - font-size: 13px; - line-height: 1; - transform: translateY(-2px); -} - -/* 文字:每次进入时播放一次 1.2s 入场动画;离开后重置以便下次重放 */ -.section-3 .deloc, -.section-3 .deloc-sub, -.section-3 .enter-btn { - opacity: 0; -} - -.section-3.enter .deloc { - animation: deloc-in 1.2s ease forwards; -} - -.section-3.enter .deloc-sub { - animation: deloc-in 1.2s ease 0.15s forwards; -} - -.section-3.enter .enter-btn { - animation: deloc-in 1.2s ease 0.3s forwards; -} - -@keyframes deloc-in { - from { - opacity: 0; - transform: translateY(28px); - } - to { - opacity: 1; - transform: none; - } -} - -/* 第一/二/四面文字:与第三面相同的“进入播放一次 + 随滚动淡出” */ -.js .hero .hero-title, -.js .hero .accent-bar, -.js .about .section-head, -.js .about .about-grid, -.js .future .future-title, -.js .future .future-sub, -.js .future .future-body { - opacity: 0; -} - -.hero.enter .hero-title, -.about.enter .section-head, -.future.enter .future-title { - animation: sec-text-in 1.2s ease forwards; -} - -.hero.enter .accent-bar, -.about.enter .about-grid, -.future.enter .future-sub { - animation: sec-text-in 1.2s ease 0.12s forwards; -} - -@keyframes sec-text-in { - from { opacity: 0; transform: translateY(28px); } - to { opacity: 1; transform: none; } -} - -/* 第四面:与前面相同的淡进/淡出,但整体比它们晚 0.5s */ -.future { - transition: opacity 0.45s ease 0.5s; -} - -.future.enter .future-title { - animation: sec-text-in 1.2s ease 0.5s forwards; -} - -.future.enter .future-sub { - animation: sec-text-in 1.2s ease 0.62s forwards; -} - -.future.enter .future-body { - animation: sec-text-in 1.2s ease 0.74s forwards; -} - -.future-body { - max-width: 56ch; - margin-top: 1.4rem; - font-family: var(--font); - font-size: 1.05rem; - line-height: 1.9; - color: var(--ink-soft); - text-align: justify; -} - -/* ---------- DC page hand-off ---------- */ -/* Keep the page visible underneath so the transfer reads as a deliberate hand-off, - then let the independent boot screen take over. */ -.dc-page-transition { - position: fixed; - inset: 0; - z-index: 100000; - display: grid; - place-items: center; - padding: 24px; - color: #e5c76f; - background: - radial-gradient(circle at center, rgba(184, 148, 56, 0.16), transparent 30%), - rgba(13, 11, 8, 0.98); - opacity: 0; - visibility: hidden; - pointer-events: none; - transition: opacity 0.5s ease, visibility 0s linear 0.5s; -} - -.dc-page-transition.is-active { - opacity: 1; - visibility: visible; - transition: opacity 0.5s ease; -} - -.dc-transition-core { - position: relative; - display: grid; - place-items: center; - width: min(190px, 52vw); - aspect-ratio: 1; - isolation: isolate; - opacity: 0; - transform: scale(0.9) translateZ(0); - will-change: opacity, transform; - transition: opacity 0.5s ease, transform 0.5s cubic-bezier(.22, .61, .36, 1); -} - -.dc-page-transition.is-active .dc-transition-core { - opacity: 1; - transform: scale(1) translateZ(0); -} - -.dc-transition-orbit { - position: absolute; - width: 100%; - aspect-ratio: 1; - border: 1px solid rgba(229, 199, 111, 0.58); - border-radius: 50%; - transform: translateZ(0); - will-change: transform, opacity; -} - -.dc-transition-orbit-b { - width: 72%; - transform: rotateX(68deg) rotateZ(-30deg); - border-color: rgba(229, 199, 111, 0.34); -} - -.dc-page-transition.is-active .dc-transition-orbit-a { - animation: dc-orbit-a 0.78s cubic-bezier(.22, .61, .36, 1) both; -} - -.dc-page-transition.is-active .dc-transition-orbit-b { - animation: dc-orbit-b 0.78s 0.06s cubic-bezier(.22, .61, .36, 1) both; -} - -.dc-transition-signal { - width: 13px; - aspect-ratio: 1; - border-radius: 50%; - background: #e5c76f; - box-shadow: 0 0 0 7px rgba(229, 199, 111, 0.11), 0 0 18px rgba(229, 199, 111, 0.7); - will-change: transform, opacity; -} - -.dc-page-transition.is-active .dc-transition-signal { - animation: dc-signal 0.72s 0.1s ease-in-out both; -} - -.dc-transition-core p { - position: absolute; - top: calc(100% + 20px); - width: max-content; - max-width: 80vw; - margin: 0; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.22em; - text-align: center; -} - -@keyframes dc-orbit-a { - from { transform: translateZ(0) rotate(-36deg); opacity: 0; } - to { transform: translateZ(0) rotate(0deg); opacity: 1; } -} - -@keyframes dc-orbit-b { - from { transform: rotateX(68deg) rotateZ(-76deg); opacity: 0; } - to { transform: rotateX(68deg) rotateZ(-30deg); opacity: 1; } -} - -@keyframes dc-signal { - from { transform: scale(0.5); opacity: 0; } - to { transform: scale(1); opacity: 1; } -} - -@media (prefers-reduced-motion: reduce) { - .dc-page-transition, - .dc-page-transition.is-active, - .dc-transition-core { transition-duration: 0.01ms; } - .dc-transition-orbit, - .dc-transition-signal { animation: none; } -} - -/* ---------- Responsive ---------- */ -@media (max-width: 720px) { - .about-grid { - grid-template-columns: 1fr; - } - .about-photo { - max-width: 320px; - width: 100%; - margin-inline: auto; - } - .hero-title { flex-direction: column; gap: 0.1em; } -} - -.venus-preview { - position: relative; - isolation: isolate; - min-height: 100vh; - min-height: 100svh; - height: 100vh; - height: 100svh; - margin: 0; - padding: 8vh 7vw 10vh; - display: grid; - grid-template-columns: minmax(0, .9fr) minmax(0, 1.25fr); - gap: 6vw; - overflow: hidden; - color: #f1dfbd; - background: radial-gradient(ellipse at 72% 50%, #3b2818 0%, #18130e 42%, #100e0b 80%); -} -.venus-preview::before { - position: absolute; - inset: 0; - content: ''; - pointer-events: none; - opacity: .18; - background-image: linear-gradient(#b8943826 1px, transparent 1px), linear-gradient(90deg, #b8943826 1px, transparent 1px); - background-size: 96px 96px; -} -.venus-preview-copy { - position: relative; - z-index: 2; - align-self: center; - min-width: 0; -} -.venus-eyebrow { - margin: 0 0 30px; - color: #bba582; - font: 10px/1.5 var(--font); - letter-spacing: .25em; -} -#venus-title { - margin: 0; - font-family: var(--font-display); - font-size: clamp(36px, 7vw, 108px); - font-weight: 700; - line-height: 1.08; - letter-spacing: -.045em; -} -#venus-title span { - display: block; - margin-top: 18px; - font: 300 19px/1.4 var(--font); - letter-spacing: .5em; -} -.venus-preview-description { margin: 32px 0 0; color: #baac97; font-size: 14px; line-height: 2; } -.venus-preview .enter-btn { margin-top: 36px; padding: 15px 26px; font-size: 12px; letter-spacing: .1em; } -.venus-preview-stage { - position: relative; - min-width: 0; - min-height: 0; - pointer-events: none; -} -#venus-preview-canvas { - position: absolute; - inset: 0; - visibility: hidden; - opacity: 0; - transition: opacity .45s ease; -} -.venus-preview-stage.is-ready #venus-preview-canvas { - visibility: visible; - opacity: 1; -} -#venus-preview-canvas canvas { display: block; width: 100%; height: 100%; } -.venus-preview-fallback { display: none; position: absolute; bottom: 5%; width: 100%; text-align: center; font-size: 12px; color: #baac97; } -.venus-preview-stage.is-error .venus-preview-fallback { display: block; } -.venus-preview-caption { - position: absolute; - inset: auto 7vw 24px; - display: flex; - justify-content: space-between; - gap: 20px; - border-top: 1px solid #b8943833; - padding-top: 15px; - color: #9c886b; - font-size: 9px; - letter-spacing: .17em; -} -@media (max-width: 640px) { - .venus-preview { min-height: 100vh; min-height: 100svh; height: 100vh; height: 100svh; margin: 0; padding: clamp(24px, 5svh, 44px) 7vw 58px; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 24px; } - .venus-preview-copy { align-self: start; } - .venus-eyebrow { margin-bottom: 18px; font-size: 8px; } - #venus-title { font-size: clamp(34px, 11vw, 60px); } - #venus-title span { display: inline-block; margin: 0 0 0 15px; font-size: 14px; vertical-align: middle; } - .venus-preview-description { margin-top: 20px; font-size: 12px; line-height: 1.9; } - .venus-preview .enter-btn { margin-top: 22px; padding: 13px 20px; font-size: 10px; } - .venus-preview-caption { bottom: 16px; font-size: 7px; padding-top: 10px; gap: 8px; } -} - -/* Use the same replay-on-entry rhythm as the other home sections. */ -.js .venus-eyebrow, -.js #venus-title, -.js .venus-preview-description, -.js .venus-preview .enter-btn, -.js .venus-preview-stage { opacity: 0; } -.venus-preview.enter .venus-eyebrow, -.venus-preview.enter #venus-title { animation: sec-text-in 1.2s ease forwards; } -.venus-preview.enter .venus-preview-description { animation: sec-text-in 1.2s .12s ease forwards; } -.venus-preview.enter .enter-btn { animation: sec-text-in 1.2s .24s ease forwards; } -.venus-preview.enter .venus-preview-stage { animation: venus-globe-in 1.2s .12s ease forwards; } -@keyframes venus-globe-in { from { opacity: 0; } to { opacity: 1; } } -@media (prefers-reduced-motion: reduce) { - .js .venus-preview .venus-eyebrow, - .js .venus-preview #venus-title, - .js .venus-preview .venus-preview-description, - .js .venus-preview .enter-btn, - .js .venus-preview .venus-preview-stage { opacity: 1; animation: none; } -} -@media (max-height: 600px) and (min-width: 641px) { - .venus-eyebrow { margin-bottom: 10px; } - #venus-title { font-size: 9svh; } - #venus-title span { margin-top: 8px; font-size: 14px; } - .venus-preview-description { margin-top: 12px; font-size: 12px; } - .venus-preview .enter-btn { margin-top: 12px; padding: 10px 18px; } -} - -.mars-preview { - position: relative; - isolation: isolate; - min-height: 100vh; - min-height: 100svh; - height: 100vh; - height: 100svh; - margin: 0; - padding: 8vh 7vw 10vh; - display: grid; - grid-template-columns: minmax(0, .9fr) minmax(0, 1.25fr); - gap: 6vw; - overflow: hidden; - color: #f1dfbd; - background: radial-gradient(ellipse at 72% 50%, #49281f 0%, #21120f 42%, #120b0a 80%); -} -.mars-preview::before { - position: absolute; - inset: 0; - content: ''; - pointer-events: none; - opacity: .18; - background-image: linear-gradient(#b8943826 1px, transparent 1px), linear-gradient(90deg, #b8943826 1px, transparent 1px); - background-size: 96px 96px; -} -.mars-preview-copy { - position: relative; - z-index: 2; - align-self: center; - min-width: 0; -} -.mars-eyebrow { - margin: 0 0 30px; - color: #bba582; - font: 10px/1.5 var(--font); - letter-spacing: .25em; -} -#mars-title { - margin: 0; - font-family: var(--font-display); - font-size: clamp(36px, 7vw, 108px); - font-weight: 700; - line-height: 1.08; - letter-spacing: -.045em; -} -#mars-title span { - display: block; - margin-top: 18px; - font: 300 19px/1.4 var(--font); - letter-spacing: .5em; -} -.mars-preview-description { margin: 32px 0 0; color: #baac97; font-size: 14px; line-height: 2; } -.mars-preview .enter-btn { margin-top: 36px; padding: 15px 26px; font-size: 12px; letter-spacing: .1em; } -.mars-preview-stage { - position: relative; - min-width: 0; - min-height: 0; - pointer-events: none; -} -#mars-preview-canvas { - position: absolute; - inset: 0; - visibility: hidden; - opacity: 0; - transition: opacity .45s ease; -} -.mars-preview-stage.is-ready #mars-preview-canvas { - visibility: visible; - opacity: 1; -} -#mars-preview-canvas canvas { display: block; width: 100%; height: 100%; } -.mars-preview-fallback { display: none; position: absolute; bottom: 5%; width: 100%; text-align: center; font-size: 12px; color: #baac97; } -.mars-preview-stage.is-error .mars-preview-fallback { display: block; } -.mars-preview-caption { - position: absolute; - inset: auto 7vw 24px; - display: flex; - justify-content: space-between; - gap: 20px; - border-top: 1px solid #b8943833; - padding-top: 15px; - color: #9c886b; - font-size: 9px; - letter-spacing: .17em; -} -@media (max-width: 640px) { - .mars-preview { min-height: 100vh; min-height: 100svh; height: 100vh; height: 100svh; margin: 0; padding: clamp(24px, 5svh, 44px) 7vw 58px; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 24px; } - .mars-preview-copy { align-self: start; } - .mars-eyebrow { margin-bottom: 18px; font-size: 8px; } - #mars-title { font-size: clamp(34px, 11vw, 60px); } - #mars-title span { display: inline-block; margin: 0 0 0 15px; font-size: 14px; vertical-align: middle; } - .mars-preview-description { margin-top: 20px; font-size: 12px; line-height: 1.9; } - .mars-preview .enter-btn { margin-top: 22px; padding: 13px 20px; font-size: 10px; } - .mars-preview-caption { bottom: 16px; font-size: 7px; padding-top: 10px; gap: 8px; } -} - -/* Use the same replay-on-entry rhythm as the other home sections. */ -.js .mars-eyebrow, -.js #mars-title, -.js .mars-preview-description, -.js .mars-preview .enter-btn, -.js .mars-preview-stage { opacity: 0; } -.mars-preview.enter .mars-eyebrow, -.mars-preview.enter #mars-title { animation: sec-text-in 1.2s ease forwards; } -.mars-preview.enter .mars-preview-description { animation: sec-text-in 1.2s .12s ease forwards; } -.mars-preview.enter .enter-btn { animation: sec-text-in 1.2s .24s ease forwards; } -.mars-preview.enter .mars-preview-stage { animation: mars-globe-in 1.2s .12s ease forwards; } -@keyframes mars-globe-in { from { opacity: 0; } to { opacity: 1; } } -@media (prefers-reduced-motion: reduce) { - .js .mars-preview .mars-eyebrow, - .js .mars-preview #mars-title, - .js .mars-preview .mars-preview-description, - .js .mars-preview .enter-btn, - .js .mars-preview .mars-preview-stage { opacity: 1; animation: none; } -} -@media (max-height: 600px) and (min-width: 641px) { - .mars-eyebrow { margin-bottom: 10px; } - #mars-title { font-size: 9svh; } - #mars-title span { margin-top: 8px; font-size: 14px; } - .mars-preview-description { margin-top: 12px; font-size: 12px; } - .mars-preview .enter-btn { margin-top: 12px; padding: 10px 18px; } -} - -/* Full-screen index, in the same warm paper and muted gold as the main site. */ -.site-nav-toggle { - position: fixed; - top: max(20px, env(safe-area-inset-top)); - right: max(22px, env(safe-area-inset-right)); - z-index: 10000; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 18px; - min-width: 108px; - min-height: 46px; - padding: 12px 18px; - border: 1px solid rgba(184, 148, 56, .5); - border-radius: 999px; - background: rgba(22, 19, 14, .94); - color: #f2e9d8; - font: 500 12px/1.2 var(--font); - letter-spacing: .12em; - cursor: pointer; - box-shadow: 0 6px 24px #0002; - transition: background .2s ease, transform .2s ease; -} -.site-nav-toggle[hidden] { display: none; } -.site-nav-toggle:hover { background: #3d3220; transform: translateY(-2px); } -#site-nav-toggle { - min-width: 46px; - width: 46px; - height: 46px; - padding: 10px; - border: 0; - border-radius: 0; - background: transparent; - color: #14120e; - box-shadow: none; - transition: opacity .25s ease, transform .25s ease; -} -#site-nav-toggle.is-on-dark-background { color: #fff; } -#site-nav-toggle:hover { background: transparent; transform: none; } -#site-nav-toggle.is-idle-hidden { opacity: 0; transform: translateY(-5px); pointer-events: none; } -.nav-hamburger { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; } -.site-navigation { - position: fixed; - inset: 0; - width: 100%; max-width: none; - height: 100%; max-height: none; - margin: 0; - padding: 0; - border: 0; - color: #30291f; - background-color: #f3eee4; - background-image: linear-gradient(#8a72500a 1px, transparent 1px), linear-gradient(90deg, #8a72500a 1px, transparent 1px); - background-size: 96px 96px; - overflow-y: auto; - overscroll-behavior: contain; -} -.site-navigation[open] { display: flex; flex-direction: column; animation: navigation-in .35s ease both; } -.site-navigation::backdrop { background: #15110d70; } -html.site-menu-open { overflow: hidden; } -.navigation-top { padding: 30px 7vw; min-height: 90px; display: flex; align-items: center; } -.navigation-brand { font-family: var(--font-display); font-size: 20px; font-weight: 700; letter-spacing: -.045em; } -.navigation-brand > span { color: #aa9476; } -.navigation-content { - flex: 1 0 auto; - width: 100%; max-width: 1440px; - margin: auto; padding: 6vh 9vw; - display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); - align-items: center; gap: 8vw; -} -.navigation-kicker { margin: 0 0 24px; font-size: 10px; letter-spacing: .25em; color: #a08557; } -.navigation-intro h2 { margin: 0 0 24px; font-size: clamp(28px, 3.4vw, 50px); font-weight: 400; letter-spacing: .03em; } -.navigation-intro > p:last-child { margin: 0; color: #9c8d7a; font-size: 12px; line-height: 1.8; } -.navigation-links { list-style: none; padding: 0; margin: 0; border-top: 1px solid #ad956644; } -.navigation-links li { border-bottom: 1px solid #ad956644; } -.navigation-links a { - display: grid; grid-template-columns: 24px minmax(0, 1fr) 26px; - align-items: center; gap: 18px; - padding: clamp(18px, 3vh, 34px) 6px; - text-decoration: none; color: inherit; - transition: color .2s ease, background .2s ease; -} -.navigation-number { align-self: start; margin-top: 8px; font-size: 10px; color: #a18a65; letter-spacing: .06em; } -.navigation-label { font-size: clamp(26px, 3.2vw, 44px); font-weight: 400; line-height: 1.25; letter-spacing: .06em; } -.navigation-label small { display: block; margin-top: 9px; font-size: 9px; line-height: 1.5; letter-spacing: .19em; color: #9c8d7a; } -.navigation-arrow { font-size: 23px; color: #b99d68; transition: transform .2s ease; } -.navigation-links a:hover, .navigation-links a[aria-current="location"] { color: #9a772d; background: #b8943809; } -.navigation-links a:hover .navigation-arrow { transform: translate(3px, -3px); } -.navigation-links a[aria-current="location"] .navigation-number { font-weight: 700; } -.site-nav-toggle:focus-visible, .navigation-links a:focus-visible { outline: 2px solid #b89438; outline-offset: 4px; } -.navigation-bottom { display: flex; justify-content: space-between; gap: 20px; margin: 0 7vw; padding: 24px 0 max(24px, env(safe-area-inset-bottom)); border-top: 1px solid #ad956633; font-size: 8px; letter-spacing: .13em; color: #aa9678; } -#about, #dc, #venus, #contact { scroll-margin-top: 0; } -#about:focus, #dc:focus, #venus:focus, #contact:focus { outline: none; } -@keyframes navigation-in { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: none; } } -@media (max-width: 700px) { - .site-nav-toggle { top: max(16px, env(safe-area-inset-top)); right: 16px; min-width: 96px; gap: 14px; } - .navigation-top { padding: 22px 7vw; min-height: 80px; } - .navigation-brand { font-size: 18px; } - .navigation-content { display: block; padding: 3vh 8vw 5vh; } - .navigation-intro { margin-bottom: 26px; } - .navigation-kicker { margin-bottom: 12px; font-size: 9px; } - .navigation-intro h2 { font-size: 26px; margin-bottom: 12px; } - .navigation-intro > p:last-child { font-size: 11px; } - .navigation-links a { padding: 20px 4px; gap: 14px; } - .navigation-label { font-size: 27px; } - .navigation-bottom { font-size: 7px; } - .navigation-bottom > span:last-child { display: none; } -} -@media (max-height: 600px) { - .navigation-content { padding-top: 16px; padding-bottom: 20px; } - .navigation-links a { padding-block: 14px; } -} -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - .site-navigation[open] { animation: none; } - .site-nav-toggle, .navigation-links a, .navigation-arrow { transition: none; } -} diff --git a/index.html b/index.html index f157fdc..2fcaade 100644 --- a/index.html +++ b/index.html @@ -1,198 +1,18 @@ - + + - empty610's website + website · empty610 + - - + - + - -
- - -
-
-

- Loading - -

- -
-
-
-
- 0% -
-
-
- -
-
-

- WELCOME - 欢迎 -

- -
- - -
-
- -

关于我

- -
- -
-
-

你好,我是empty610。

-

这是我的个人网站,我会在这里持续发布内容,并更新我的系统作品集与项目。

-

我是一个天文学爱好者,所以你能看到我的项目大多与天文学相关,我希望通过这些项目能够分享我对天文学的热情和见解,也希望它能对你有所帮助。

-

接下来的内容会是一些闲谈。我能接触到游戏《明日方舟》得益于朋友的推荐,通过它,我接触了非常多种的艺术风格,你会看到我的作品灵感大部分来自于《明日方舟》。

-

我还想在这里说一说《少女前线》这款游戏,我很感谢它给我带来的乐趣,它和《明日方舟:终末地》一样,构成了我游戏娱乐的大部分,并深刻地影响了我。 - 接着,请允许我向你推荐漫画《少女的终末旅行》,它引发了一些我的思考。

-

最后,感谢你来到我的网站浏览我发布的内容。 -

-
-
- 我的照片 -
-
-
- - -
-
-
- DELOCALIZED - CONFIGURATION -
-

A DISTRIBUTED SPACE INFRASTRUCTURE INITIATIVE

- - GO TERMINAL - - -
-
- - -
-
-

SOLAR LABORATORY / 02

-

VENUS金星

-

自太阳系宜居带诞生的金色炼狱。
A golden world of extremes born from the habitable zone.

- EXPLORE VENUS -
- -
MAGELLAN RADAR SURFACEVENUS PREVIEW · 02
-
- -
-
-

SOLAR LABORATORY / 03

-

MARS火星

-

或许曾拥有奕奕生机的红色荒原。
A red wastedland that perhaps once had radiant life.

- EXPLORE MARS -
- -
MARS GLOBAL SURFACEMARS PREVIEW · 03
-
- - -
-

未来呢?

-

这里暂时没有更多的内容。

- -

或许我会在不久之后实装一些项目在这,这会包括一个能帮助你了解太阳系的模型,以及针对太阳系各大行星的介绍,现在已经做了金星与火星,我正在思考是否要新建一个站点专门存放它。你或许可以从这里了解到人类对太阳系各大行星的探索程度与主要的科研成果。但至少现在,这里还什么都没有。

-

在此,再次感谢你观看我的网站,并祝愿你看得开心。如果你能在我的编辑的内容中获得一些对你有用的东西,我不胜荣幸。

-

- - -
-
- -

联系

- -
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - + +
diff --git a/llms.txt b/llms.txt deleted file mode 100644 index d2fdfa4..0000000 --- a/llms.txt +++ /dev/null @@ -1,7 +0,0 @@ -# EMPTY610 - -EMPTY610 is a personal website and experimental digital space. - -## Website - -- [EMPTY610 Home](https://empty610.com/) diff --git a/mars-preview.js b/mars-preview.js deleted file mode 100644 index ae0d895..0000000 --- a/mars-preview.js +++ /dev/null @@ -1,24 +0,0 @@ -const section = document.getElementById('mars'); -const stage = document.getElementById('mars-preview-stage'); -const mount = document.getElementById('mars-preview-canvas'); -let started = false; -async function startPreview() { - if (started) return; - started = true; - try { - const { mountVenus } = await import('./venus/model.js?v=20260923-mars'); - await mountVenus(stage, mount, { preview: true, surface: '../mars/assets/mars-texture.jpg', tilt: 25.19 }); - } catch { - stage.classList.add('is-error'); - } -} -if (section && stage && mount) { - if ('IntersectionObserver' in window) { - const observer = new IntersectionObserver(([entry]) => { - if (!entry.isIntersecting) return; - observer.disconnect(); - startPreview(); - }, { rootMargin: '240px 0px' }); - observer.observe(section); - } else startPreview(); -} diff --git a/mars/site.js b/mars/site.js deleted file mode 100644 index cecb894..0000000 --- a/mars/site.js +++ /dev/null @@ -1,3 +0,0 @@ -if (location.protocol === 'file:') { - document.getElementById('mars-back').href = '../index.html#mars'; -} diff --git a/music.js b/music.js deleted file mode 100644 index 3f44cbb..0000000 --- a/music.js +++ /dev/null @@ -1,77 +0,0 @@ -(() => { - const audio = document.getElementById('bg-music'); - if (!audio) return; - const terminal = document.body.dataset.page === 'terminal'; - const toggle = document.getElementById('music-toggle'); - const preference = 'site.music.mainEnabled'; - const position = 'site.music.time'; - const get = (key) => { try { return sessionStorage.getItem(key); } catch { return null; } }; - const set = (key, value) => { try { sessionStorage.setItem(key, value); } catch {} }; - let mainEnabled = get(preference) === '1'; - let resumePending = false; - - function savePosition() { - if (Number.isFinite(audio.currentTime) && audio.currentTime > 0) { - set(position, String(audio.currentTime)); - } - } - function restorePosition() { - const time = Number(get(position)); - if (Number.isFinite(time) && time > 0) { - try { audio.currentTime = time; } catch {} - } - } - function updateIcon() { - document.documentElement.classList.toggle('music-playing', !audio.paused); - toggle?.setAttribute('aria-pressed', String(!audio.paused)); - } - async function play() { - audio.volume = 0.4; - audio.muted = false; - try { - await audio.play(); - resumePending = false; - } catch { - // Browsers can require a new gesture after a full page navigation. - resumePending = !terminal && mainEnabled; - } - } - toggle?.addEventListener('click', () => { - if (audio.paused) { - mainEnabled = true; - set(preference, '1'); - restorePosition(); - play(); - } else { - mainEnabled = false; - resumePending = false; - set(preference, '0'); - savePosition(); - audio.pause(); - } - }); - ['pointerdown', 'keydown'].forEach(type => document.addEventListener(type, event => { - if (resumePending && !toggle?.contains(event.target)) play(); - }, { passive: true })); - audio.addEventListener('play', updateIcon); - audio.addEventListener('pause', updateIcon); - setInterval(() => { if (!audio.paused) savePosition(); }, 2000); - window.addEventListener('pagehide', () => { - savePosition(); - audio.pause(); - }); - window.addEventListener('pageshow', () => { - mainEnabled = get(preference) === '1'; - if (!terminal && mainEnabled) { - restorePosition(); - play(); - } - }); - window.SiteMusic = { - startTerminal() { - if (!terminal) return; - restorePosition(); - play(); - } - }; -})(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..7cb03b7 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "website", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Personal website and astronomy projects by empty610", + "author": "empty610", + "scripts": { + "dev": "bun --bun vite", + "build": "bun --bun vite build && bun scripts/prerender.js", + "preview": "bun --bun vite preview", + "check": "bun scripts/check-build.js" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.9", + "@vue/server-renderer": "^3.5.43", + "vite": "^8.3.2" + }, + "dependencies": { + "vue": "^3.5.43", + "vue-router": "4" + } +} diff --git a/page-init.js b/page-init.js deleted file mode 100644 index 3c8d9f8..0000000 --- a/page-init.js +++ /dev/null @@ -1 +0,0 @@ -document.documentElement.classList.add('js'); diff --git a/_headers b/public/_headers similarity index 100% rename from _headers rename to public/_headers diff --git a/assets/textures/earth_atmos_2048.data.js b/public/data/earth/earth_atmos_2048.data.js similarity index 100% rename from assets/textures/earth_atmos_2048.data.js rename to public/data/earth/earth_atmos_2048.data.js diff --git a/mars/assets/mars-texture.js b/public/data/mars/mars-texture.js similarity index 100% rename from mars/assets/mars-texture.js rename to public/data/mars/mars-texture.js diff --git a/mars/assets/moons/moon-assets.js b/public/data/mars/moon-assets.js similarity index 100% rename from mars/assets/moons/moon-assets.js rename to public/data/mars/moon-assets.js diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..1b4b662 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,7 @@ +# website + +website is a personal website and experimental digital space by empty610. + +## Website + +- [EMPTY610 Home](https://empty610.com/) diff --git a/mars/vendor/LICENSE.txt b/public/vendor/mars/LICENSE.txt similarity index 100% rename from mars/vendor/LICENSE.txt rename to public/vendor/mars/LICENSE.txt diff --git a/mars/vendor/OrbitControls.js b/public/vendor/mars/OrbitControls.js similarity index 100% rename from mars/vendor/OrbitControls.js rename to public/vendor/mars/OrbitControls.js diff --git a/mars/vendor/three.js b/public/vendor/mars/three.js similarity index 100% rename from mars/vendor/three.js rename to public/vendor/mars/three.js diff --git a/vendor/three/LICENSE b/public/vendor/three/LICENSE similarity index 100% rename from vendor/three/LICENSE rename to public/vendor/three/LICENSE diff --git a/vendor/three/README.md b/public/vendor/three/README.md similarity index 100% rename from vendor/three/README.md rename to public/vendor/three/README.md diff --git a/vendor/three/build/three.min.js b/public/vendor/three/build/three.min.js similarity index 100% rename from vendor/three/build/three.min.js rename to public/vendor/three/build/three.min.js diff --git a/vendor/three/examples/jsm/controls/OrbitControls.global.js b/public/vendor/three/examples/jsm/controls/OrbitControls.global.js similarity index 100% rename from vendor/three/examples/jsm/controls/OrbitControls.global.js rename to public/vendor/three/examples/jsm/controls/OrbitControls.global.js diff --git a/vendor/three/examples/jsm/controls/OrbitControls.js b/public/vendor/three/examples/jsm/controls/OrbitControls.js similarity index 100% rename from vendor/three/examples/jsm/controls/OrbitControls.js rename to public/vendor/three/examples/jsm/controls/OrbitControls.js diff --git a/scripts/build-home-css.cjs b/scripts/build-home-css.cjs deleted file mode 100644 index 8332ea2..0000000 --- a/scripts/build-home-css.cjs +++ /dev/null @@ -1,7 +0,0 @@ -// Keep editable sources separate while serving one render-blocking request. -const fs = require('node:fs'); -const path = require('node:path'); -const root = path.resolve(__dirname, '..'); -const sources = ['style.css', 'venus-entry.css', 'mars-entry.css', 'navigation.css']; -const css = sources.map(file => fs.readFileSync(path.join(root, file), 'utf8')).join('\n'); -fs.writeFileSync(path.join(root, 'home.css'), '/* Generated by scripts/build-home-css.cjs */\n' + css); diff --git a/scripts/check-build.js b/scripts/check-build.js new file mode 100644 index 0000000..2400228 --- /dev/null +++ b/scripts/check-build.js @@ -0,0 +1,97 @@ +import { readdir, readFile, access } from 'node:fs/promises'; +import { resolve, dirname, extname } from 'node:path'; + +const root = resolve('dist'); +const failures = []; +let checked = 0; +const canonicals = new Set(); +const titles = new Set(); +const descriptions = new Set(); + +async function verifySeo(html, file) { + const title = html.match(/(.*?)<\/title>/s)?.[1]; + const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map(([tag]) => ({ + key: tag.match(/(?:name|property)="([^"]+)"/)?.[1], + value: tag.match(/content="([^"]*)"/)?.[1], + })); + const meta = key => metas.filter(item => item.key === key); + const description = meta('description'); + const canonical = [...html.matchAll(/<link\b[^>]*rel="canonical"[^>]*href="([^"]+)"/g)].map(match => match[1]); + if (!title || titles.has(title)) failures.push(`${file}: missing or duplicate page title`); + if (description.length !== 1 || !description[0].value || descriptions.has(description[0].value)) failures.push(`${file}: missing or duplicate description`); + if (canonical.length !== 1 || !canonical[0].startsWith('https://') || canonicals.has(canonical[0])) failures.push(`${file}: missing or duplicate canonical URL`); + if ([...html.matchAll(/<h1\b/g)].length !== 1) failures.push(`${file}: expected one main heading`); + titles.add(title); + descriptions.add(description[0]?.value); + canonicals.add(canonical[0]); + for (const [key, expected] of [['og:title', title], ['og:description', description[0]?.value], ['og:url', canonical[0]], ['twitter:card', 'summary_large_image']]) { + const values = meta(key); + if (values.length !== 1 || values[0].value !== expected) failures.push(`${file}: inconsistent ${key}`); + } + const image = meta('og:image')[0]?.value; + if (!image?.startsWith('https://') || meta('twitter:image')[0]?.value !== image) failures.push(`${file}: missing or inconsistent share image`); + else await verify(new URL(image).pathname, file); + if (meta('robots').length !== 1 || !meta('robots')[0].value.startsWith('index,follow')) failures.push(`${file}: unexpected indexing policy`); + const schemas = [...html.matchAll(/<script\b[^>]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/gs)]; + try { + if (schemas.length !== 1) throw new Error('expected one JSON-LD block'); + const graph = JSON.parse(schemas[0][1])['@graph']; + if (graph.find(item => item['@type'] === 'WebPage')?.url !== canonical[0] || graph.find(item => item['@type'] === 'Person')?.name !== 'empty610') throw new Error('inconsistent page or author'); + } catch (error) { failures.push(`${file}: invalid structured data: ${error.message}`); } + if (html.includes('/src/assets/') || html.includes('<!--seo-head-->')) failures.push(`${file}: unresolved source or metadata`); +} + +async function verify(reference, file) { + if (!reference || /^(?:[a-z]+:|\/\/|#)/i.test(reference)) return; + const pathname = decodeURIComponent(reference.split(/[?#]/)[0]); + let target = resolve(pathname.startsWith('/') ? root : dirname(file), '.' + (pathname.startsWith('/') ? pathname : '/' + pathname)); + if (!target.startsWith(root + '/') && target !== root) { + failures.push(`${file}: reference escapes dist: ${reference}`); + return; + } + if (pathname.endsWith('/') || target === root) target = resolve(target, 'index.html'); + try { await access(target); checked++; } + catch { failures.push(`${file}: missing ${reference}`); } +} + +async function walk(directory) { + for (const item of await readdir(directory, { withFileTypes: true })) { + const file = resolve(directory, item.name); + if (item.isDirectory()) { await walk(file); continue; } + const extension = extname(file); + if (!['.html', '.css'].includes(extension)) continue; + const text = await readFile(file, 'utf8'); + const pattern = extension === '.html' + ? /(?:src|href|data-image-full)="([^"]+)"/g + : /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/g; + for (const match of text.matchAll(pattern)) await verify(match[1] ?? match[2] ?? match[3], file); + if (text.includes('@include')) failures.push(`${file}: unresolved HTML include`); + if (extension === '.html' && !text.includes('Content-Security-Policy')) failures.push(`${file}: production CSP missing`); + if (extension === '.html') { + await verifySeo(text, file); + const ids = [...text.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); + if (new Set(ids).size !== ids.length) failures.push(`${file}: duplicate HTML IDs`); + if ((!text.includes('<h1') && !ids.includes('main-ui')) || !ids.includes('website-app') || !ids.includes('music-toggle')) { + failures.push(`${file}: missing prerendered content or shared controls`); + } + if (!text.includes('name="author" content="empty610"')) failures.push(`${file}: missing author attribution`); + } + } +} + +const manifest = JSON.parse(await readFile(resolve(root, '.vite/manifest.json'), 'utf8')); +for (const entry of Object.values(manifest)) { + for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) { + await verify('/' + file, resolve(root, 'index.html')); + } +} +await walk(root); +const sitemap = await readFile(resolve(root, 'sitemap.xml'), 'utf8'); +const urls = [...sitemap.matchAll(/<loc>(.*?)<\/loc>/g)].map(match => match[1]); +if (urls.length !== canonicals.size || new Set(urls).size !== urls.length || urls.some(url => !canonicals.has(url))) failures.push('Sitemap URLs do not match canonical pages'); +const robots = await readFile(resolve(root, 'robots.txt'), 'utf8'); +if (!robots.includes('Allow: /') || !robots.includes(`Sitemap: ${new URL('/sitemap.xml', urls[0]).href}`)) failures.push('robots.txt does not expose the sitemap'); +if (failures.length) { + console.error(failures.join('\n')); + process.exitCode = 1; +} else console.log(`Build integrity passed: ${checked} local resource/link references; ${canonicals.size} pages pass SEO, sitemap and CSP checks.`); diff --git a/scripts/prerender.js b/scripts/prerender.js new file mode 100644 index 0000000..a19e39a --- /dev/null +++ b/scripts/prerender.js @@ -0,0 +1,64 @@ +import { createServer } from 'vite'; +import { renderToString } from '@vue/server-renderer'; +import { createSSRApp } from 'vue'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +// Render the same RouterView used in the browser into static route documents. +// A single source index.html serves development; hosting needs no SSR backend. +const pages = [ + ['index.html', '/', 'Home'], + ['venus/index.html', '/venus/', 'Venus'], + ['mars/index.html', '/mars/', 'Mars'], + ['delocalized configuration project/index.html', '/delocalized%20configuration%20project/', 'Terminal'], +]; +process.env.NODE_ENV = 'production'; +const manifest = JSON.parse(await readFile('dist/.vite/manifest.json', 'utf8')); +const assetPaths = Object.entries(manifest) + .filter(([path]) => path.startsWith('src/assets/')) + .map(([path, { file }]) => [`/${path}`, `/${file}`]); +const source = await readFile('dist/index.html', 'utf8'); +const server = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom' }); +function stylesFor(key, found = new Set()) { + const entry = manifest[key]; + if (!entry) throw new Error(`Missing route in Vite manifest: ${key}`); + for (const file of entry.css ?? []) found.add(file); + for (const dependency of entry.imports ?? []) stylesFor(dependency, found); + return found; +} +try { + const { default: App } = await server.ssrLoadModule('/src/App.vue'); + const { createSiteRouter } = await server.ssrLoadModule('/src/router/index.js'); + const { pages: metadata, site, canonicalUrl, renderSeoHead } = await server.ssrLoadModule('/src/seo.js'); + for (const [html, path, view] of pages) { + const router = createSiteRouter({ server: true }); + const app = createSSRApp(App).use(router); + await router.push(path); + await router.isReady(); + let markup = await renderToString(app); + for (const [original, built] of assetPaths) markup = markup.replaceAll(original, built); + if (markup.includes('/src/assets/')) throw new Error(`Unresolved source asset: ${html}`); + const marker = '<div id="website-app"></div>'; + if (!source.includes(marker)) throw new Error('Missing Vue mount point'); + const route = router.currentRoute.value; + let document = source + .replace(marker, () => `<div id="website-app">${markup}</div>`) + .replace(/<title>.*?<\/title>/, `<title>${route.meta.title}`) + .replace('', () => renderSeoHead(route.meta)) + .replaceAll('data-page="home"', `data-page="${route.name}"`); + for (const [original, built] of assetPaths) document = document.replaceAll(original, built); + const links = [...stylesFor(`src/views/${view}.vue`)] + .filter(file => !source.includes(`href="/${file}"`)) + .map(file => ``).join('\n'); + document = document.replace('', `${links}\n`); + const file = `dist/${html}`; + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, document); + console.log(`Prerendered ${html}`); + } + const urls = Object.values(metadata).map(page => ` ${canonicalUrl(page)}`).join('\n'); + await writeFile('dist/sitemap.xml', `\n\n${urls}\n\n`); + await writeFile('dist/robots.txt', `User-agent: *\nAllow: /\n\nSitemap: ${site.url}sitemap.xml\n`); +} finally { + await server.close(); +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..609120c --- /dev/null +++ b/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/ad astra.mp3 b/src/assets/audio/ad-astra.mp3 similarity index 100% rename from ad astra.mp3 rename to src/assets/audio/ad-astra.mp3 diff --git a/mars/soil-of-life.mp3 b/src/assets/audio/soil-of-life.mp3 similarity index 100% rename from mars/soil-of-life.mp3 rename to src/assets/audio/soil-of-life.mp3 diff --git a/AVGARDDO_2.woff2 b/src/assets/fonts/AVGARDDO_2.woff2 similarity index 100% rename from AVGARDDO_2.woff2 rename to src/assets/fonts/AVGARDDO_2.woff2 diff --git a/ITCAvantGardePro-Bold.woff2 b/src/assets/fonts/ITCAvantGardePro-Bold.woff2 similarity index 100% rename from ITCAvantGardePro-Bold.woff2 rename to src/assets/fonts/ITCAvantGardePro-Bold.woff2 diff --git a/ITCAvantGardePro-Book.woff2 b/src/assets/fonts/ITCAvantGardePro-Book.woff2 similarity index 100% rename from ITCAvantGardePro-Book.woff2 rename to src/assets/fonts/ITCAvantGardePro-Book.woff2 diff --git a/ITCAvantGardePro-Demi.woff2 b/src/assets/fonts/ITCAvantGardePro-Demi.woff2 similarity index 100% rename from ITCAvantGardePro-Demi.woff2 rename to src/assets/fonts/ITCAvantGardePro-Demi.woff2 diff --git a/avatar-560.webp b/src/assets/images/home/avatar-560.webp similarity index 100% rename from avatar-560.webp rename to src/assets/images/home/avatar-560.webp diff --git a/backdrop-3.webp b/src/assets/images/home/backdrop-3.webp similarity index 100% rename from backdrop-3.webp rename to src/assets/images/home/backdrop-3.webp diff --git a/mars/assets/jezero.jpg b/src/assets/images/mars/jezero.jpg similarity index 100% rename from mars/assets/jezero.jpg rename to src/assets/images/mars/jezero.jpg diff --git a/mars/assets/landform-craters.jpg b/src/assets/images/mars/landform-craters.jpg similarity index 100% rename from mars/assets/landform-craters.jpg rename to src/assets/images/mars/landform-craters.jpg diff --git a/mars/assets/landform-ice-clouds.png b/src/assets/images/mars/landform-ice-clouds.png similarity index 100% rename from mars/assets/landform-ice-clouds.png rename to src/assets/images/mars/landform-ice-clouds.png diff --git a/mars/assets/landform-tharsis.jpg b/src/assets/images/mars/landform-tharsis.jpg similarity index 100% rename from mars/assets/landform-tharsis.jpg rename to src/assets/images/mars/landform-tharsis.jpg diff --git a/mars/assets/landform-water.jpg b/src/assets/images/mars/landform-water.jpg similarity index 100% rename from mars/assets/landform-water.jpg rename to src/assets/images/mars/landform-water.jpg diff --git a/mars/assets/landform-wind.jpg b/src/assets/images/mars/landform-wind.jpg similarity index 100% rename from mars/assets/landform-wind.jpg rename to src/assets/images/mars/landform-wind.jpg diff --git a/mars/assets/mars-texture.jpg b/src/assets/images/mars/mars-texture.jpg similarity index 100% rename from mars/assets/mars-texture.jpg rename to src/assets/images/mars/mars-texture.jpg diff --git a/mars/assets/olympus.jpg b/src/assets/images/mars/olympus.jpg similarity index 100% rename from mars/assets/olympus.jpg rename to src/assets/images/mars/olympus.jpg diff --git a/mars/assets/polar.jpg b/src/assets/images/mars/polar.jpg similarity index 100% rename from mars/assets/polar.jpg rename to src/assets/images/mars/polar.jpg diff --git a/mars/assets/rovers/curiosity-concept.jpg b/src/assets/images/mars/rovers/curiosity-concept.jpg similarity index 100% rename from mars/assets/rovers/curiosity-concept.jpg rename to src/assets/images/mars/rovers/curiosity-concept.jpg diff --git a/mars/assets/rovers/curiosity-surface.jpg b/src/assets/images/mars/rovers/curiosity-surface.jpg similarity index 100% rename from mars/assets/rovers/curiosity-surface.jpg rename to src/assets/images/mars/rovers/curiosity-surface.jpg diff --git a/mars/assets/rovers/curiosity.jpg b/src/assets/images/mars/rovers/curiosity.jpg similarity index 100% rename from mars/assets/rovers/curiosity.jpg rename to src/assets/images/mars/rovers/curiosity.jpg diff --git a/mars/assets/rovers/opportunity-concept.jpg b/src/assets/images/mars/rovers/opportunity-concept.jpg similarity index 100% rename from mars/assets/rovers/opportunity-concept.jpg rename to src/assets/images/mars/rovers/opportunity-concept.jpg diff --git a/mars/assets/rovers/opportunity-surface.jpg b/src/assets/images/mars/rovers/opportunity-surface.jpg similarity index 100% rename from mars/assets/rovers/opportunity-surface.jpg rename to src/assets/images/mars/rovers/opportunity-surface.jpg diff --git a/mars/assets/rovers/opportunity.jpg b/src/assets/images/mars/rovers/opportunity.jpg similarity index 100% rename from mars/assets/rovers/opportunity.jpg rename to src/assets/images/mars/rovers/opportunity.jpg diff --git a/mars/assets/rovers/perseverance-concept.jpg b/src/assets/images/mars/rovers/perseverance-concept.jpg similarity index 100% rename from mars/assets/rovers/perseverance-concept.jpg rename to src/assets/images/mars/rovers/perseverance-concept.jpg diff --git a/mars/assets/rovers/perseverance-surface.jpg b/src/assets/images/mars/rovers/perseverance-surface.jpg similarity index 100% rename from mars/assets/rovers/perseverance-surface.jpg rename to src/assets/images/mars/rovers/perseverance-surface.jpg diff --git a/mars/assets/rovers/perseverance.jpg b/src/assets/images/mars/rovers/perseverance.jpg similarity index 100% rename from mars/assets/rovers/perseverance.jpg rename to src/assets/images/mars/rovers/perseverance.jpg diff --git a/mars/assets/rovers/spirit-concept.jpg b/src/assets/images/mars/rovers/spirit-concept.jpg similarity index 100% rename from mars/assets/rovers/spirit-concept.jpg rename to src/assets/images/mars/rovers/spirit-concept.jpg diff --git a/mars/assets/rovers/spirit-overview.jpg b/src/assets/images/mars/rovers/spirit-overview.jpg similarity index 100% rename from mars/assets/rovers/spirit-overview.jpg rename to src/assets/images/mars/rovers/spirit-overview.jpg diff --git a/mars/assets/rovers/spirit-surface.jpg b/src/assets/images/mars/rovers/spirit-surface.jpg similarity index 100% rename from mars/assets/rovers/spirit-surface.jpg rename to src/assets/images/mars/rovers/spirit-surface.jpg diff --git a/mars/assets/rovers/spirit.jpg b/src/assets/images/mars/rovers/spirit.jpg similarity index 100% rename from mars/assets/rovers/spirit.jpg rename to src/assets/images/mars/rovers/spirit.jpg diff --git a/mars/assets/rovers/zhurong-concept.png b/src/assets/images/mars/rovers/zhurong-concept.png similarity index 100% rename from mars/assets/rovers/zhurong-concept.png rename to src/assets/images/mars/rovers/zhurong-concept.png diff --git a/mars/assets/rovers/zhurong-surface.jpg b/src/assets/images/mars/rovers/zhurong-surface.jpg similarity index 100% rename from mars/assets/rovers/zhurong-surface.jpg rename to src/assets/images/mars/rovers/zhurong-surface.jpg diff --git a/mars/assets/rovers/zhurong.jpg b/src/assets/images/mars/rovers/zhurong.jpg similarity index 100% rename from mars/assets/rovers/zhurong.jpg rename to src/assets/images/mars/rovers/zhurong.jpg diff --git a/mars/assets/valles.jpg b/src/assets/images/mars/valles.jpg similarity index 100% rename from mars/assets/valles.jpg rename to src/assets/images/mars/valles.jpg diff --git a/assets/textures/earth_atmos_2048.jpg b/src/assets/images/textures/earth_atmos_2048.jpg similarity index 100% rename from assets/textures/earth_atmos_2048.jpg rename to src/assets/images/textures/earth_atmos_2048.jpg diff --git a/venus/milky-way-2mass.jpg b/src/assets/images/venus/milky-way-2mass.jpg similarity index 100% rename from venus/milky-way-2mass.jpg rename to src/assets/images/venus/milky-way-2mass.jpg diff --git a/venus/venus-atmosphere-profile.png b/src/assets/images/venus/venus-atmosphere-profile.png similarity index 100% rename from venus/venus-atmosphere-profile.png rename to src/assets/images/venus/venus-atmosphere-profile.png diff --git a/venus/venus-ishtar-overhead.jpg b/src/assets/images/venus/venus-ishtar-overhead.jpg similarity index 100% rename from venus/venus-ishtar-overhead.jpg rename to src/assets/images/venus/venus-ishtar-overhead.jpg diff --git a/venus/venus-ishtar-terra.jpg b/src/assets/images/venus/venus-ishtar-terra.jpg similarity index 100% rename from venus/venus-ishtar-terra.jpg rename to src/assets/images/venus/venus-ishtar-terra.jpg diff --git a/venus/venus-ovda-regio.jpg b/src/assets/images/venus/venus-ovda-regio.jpg similarity index 100% rename from venus/venus-ovda-regio.jpg rename to src/assets/images/venus/venus-ovda-regio.jpg diff --git a/venus/venus-surface.jpg b/src/assets/images/venus/venus-surface.jpg similarity index 100% rename from venus/venus-surface.jpg rename to src/assets/images/venus/venus-surface.jpg diff --git a/venus/venus-user-maat-mons.png b/src/assets/images/venus/venus-user-maat-mons.png similarity index 100% rename from venus/venus-user-maat-mons.png rename to src/assets/images/venus/venus-user-maat-mons.png diff --git a/venus/venus-venera13-color.jpg b/src/assets/images/venus/venus-venera13-color.jpg similarity index 100% rename from venus/venus-venera13-color.jpg rename to src/assets/images/venus/venus-venera13-color.jpg diff --git a/venus/venus-venera14-remapped-color.png b/src/assets/images/venus/venus-venera14-remapped-color.png similarity index 100% rename from venus/venus-venera14-remapped-color.png rename to src/assets/images/venus/venus-venera14-remapped-color.png diff --git a/venus/venus-volcanic-plains.jpg b/src/assets/images/venus/venus-volcanic-plains.jpg similarity index 100% rename from venus/venus-volcanic-plains.jpg rename to src/assets/images/venus/venus-volcanic-plains.jpg diff --git a/venus/venus-yavine-corona.jpg b/src/assets/images/venus/venus-yavine-corona.jpg similarity index 100% rename from venus/venus-yavine-corona.jpg rename to src/assets/images/venus/venus-yavine-corona.jpg diff --git a/style.css b/src/assets/main.css similarity index 97% rename from style.css rename to src/assets/main.css index c501d67..9abf53d 100644 --- a/style.css +++ b/src/assets/main.css @@ -1,7 +1,7 @@ /* 本地的 ITC Avant Garde Gothic Pro 多字重 */ @font-face { font-family: "Avant Garde"; - src: url("ITCAvantGardePro-Book.woff2") format("woff2"); + src: url("fonts/ITCAvantGardePro-Book.woff2") format("woff2"); font-weight: 300 500; font-style: normal; font-display: swap; @@ -9,7 +9,7 @@ @font-face { font-family: "Avant Garde"; - src: url("ITCAvantGardePro-Demi.woff2") format("woff2"); + src: url("fonts/ITCAvantGardePro-Demi.woff2") format("woff2"); font-weight: 600; font-style: normal; font-display: swap; @@ -17,7 +17,7 @@ @font-face { font-family: "Avant Garde"; - src: url("ITCAvantGardePro-Bold.woff2") format("woff2"); + src: url("fonts/ITCAvantGardePro-Bold.woff2") format("woff2"); font-weight: 700 900; font-style: normal; font-display: swap; @@ -26,7 +26,7 @@ /* 斜体(可选,仅在用到 font-style: italic 时生效) */ @font-face { font-family: "Avant Garde"; - src: url("AVGARDDO_2.woff2") format("woff2"); + src: url("fonts/AVGARDDO_2.woff2") format("woff2"); font-weight: 700; font-style: italic; font-display: swap; @@ -173,6 +173,10 @@ body { #app.is-ready .main { opacity: 1; } +/* The prerendered homepage remains readable before/without JavaScript. */ +html:not(.js) #loading { display: none; } +html:not(.js) #main[hidden] { display: block; opacity: 1; } + /* 音乐播放/暂停小按钮(固定右下角) */ .music-toggle { position: fixed; @@ -494,7 +498,7 @@ html.music-playing .music-toggle { color: #d9b95a; border-color: rgba(217, 185, padding: clamp(72px, 12vh, 150px) 8vw; margin: 0; text-align: center; - background-image: url("backdrop-3.webp"); + background-image: url("images/home/backdrop-3.webp"); background-size: cover; background-position: center; color: #e9e5dc; diff --git a/mars-entry.css b/src/assets/styles/home/mars-entry.css similarity index 100% rename from mars-entry.css rename to src/assets/styles/home/mars-entry.css diff --git a/navigation.css b/src/assets/styles/home/navigation.css similarity index 100% rename from navigation.css rename to src/assets/styles/home/navigation.css diff --git a/venus-entry.css b/src/assets/styles/home/venus-entry.css similarity index 100% rename from venus-entry.css rename to src/assets/styles/home/venus-entry.css diff --git a/mars/mars.css b/src/assets/styles/mars/mars.css similarity index 99% rename from mars/mars.css rename to src/assets/styles/mars/mars.css index 8b069eb..aca6a1b 100644 --- a/mars/mars.css +++ b/src/assets/styles/mars/mars.css @@ -733,7 +733,7 @@ border-radius: 14px; background: linear-gradient(180deg, rgba(5, 8, 14, .14), rgba(4, 6, 11, .38)), - url("../venus/milky-way-2mass.jpg") center 48% / cover no-repeat, + url("../../images/venus/milky-way-2mass.jpg") center 48% / cover no-repeat, #080b12; box-shadow: inset 0 0 44px rgba(0, 0, 0, .36); } @@ -970,11 +970,11 @@ .planet-facts small { color:var(--muted); font:11px var(--mono); } .rotation-toggle { position:absolute; right:18px; bottom:14px; z-index:3; border:1px solid var(--line-strong); border-radius:8px; padding:7px 11px; color:var(--muted); background:var(--panel); font-size:10px; cursor:pointer; } .planet-fallback img { max-width:100%; max-height:300px; border-radius:12px; } - .terrain-card[data-image-full="assets/polar.jpg"] img { object-fit:contain; background:#f1eadf; } + .terrain-card--polar img { object-fit:contain; background:#f1eadf; } .planet-stage.is-error #mars-3d { display:none; } .image-dialog-close { opacity:1 !important; pointer-events:auto !important; } .terrain-source { line-height:1.6; } - .sky-stage { background:linear-gradient(rgba(18,23,29,.75),rgba(18,23,29,.8)),url('../venus/milky-way-2mass.jpg') center/cover; } + .sky-stage { background:linear-gradient(rgba(18,23,29,.75),rgba(18,23,29,.8)),url('../../images/venus/milky-way-2mass.jpg') center/cover; } #sky-status { fill:#f1b783; } .timeline .labels { position:relative; height:13px; } .timeline .labels span { position:absolute; transform:translateX(-50%); } @@ -997,7 +997,7 @@ @media(max-width:1100px) { .landform-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } } @media(max-width:760px) { .landform-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } } @media(max-width:420px) { .landform-grid { grid-template-columns:minmax(0,1fr); } } - .landform-grid img[src="assets/landform-ice-clouds.png"] { object-position:85% center; } + .landform-grid .landform-ice-clouds { object-position:85% center; } #orbit-timeline { margin: 0; padding: 16px 18px; diff --git a/mars/mobile.css b/src/assets/styles/mars/mobile.css similarity index 93% rename from mars/mobile.css rename to src/assets/styles/mars/mobile.css index 7fcdacf..7591473 100644 --- a/mars/mobile.css +++ b/src/assets/styles/mars/mobile.css @@ -21,14 +21,12 @@ .moon-toolbar { top: 10px; width: calc(100% - 24px); max-width: 420px; gap: 2px; } .moon-toolbar button { flex: 1; min-width: 0; min-height: 42px; padding: 8px 4px; font-size: 12px; } #mars-stage.moons-ready #mars-3d, #mars-stage .moon-labels { inset: 126px 0 140px; } - #mars-stage.moons-ready .planet-intro { top: 69px; left: 12px; right: 12px; width: auto; max-width: none; min-height: 0; padding: 14px; } - #mars-stage .intro-title { min-height: 22px; } + #mars-stage.moons-ready .planet-intro { top: 69px; left: 12px; right: 12px; width: auto; max-width: none; padding: 14px; } + #mars-stage .intro-title { min-height: 24px; } #mars-stage .intro-divider { display: none; } - #mars-stage .planet-intro.is-open .intro-divider { display: block; } #mars-stage .moon-archives { top: 69px; left: 12px; width: calc(100% - 24px); } #mars-stage .moon-archive { padding: 14px; } - #mars-stage .moon-archive-title { min-height: 22px; } - #mars-stage .planet-intro.is-open, #mars-stage .moon-archive.is-open { max-height: min(380px, 56svh); } + #mars-stage .moon-archive-title { min-height: 24px; } #mars-stage .planet-intro p, #mars-stage .moon-archive p { font-size: 13px; line-height: 1.85; } .moon-info { left: 12px; right: 12px; bottom: 62px; max-width: none; padding: 12px 14px; } .moon-info strong { font-size: 14px; } diff --git a/mars/moons.css b/src/assets/styles/mars/moons.css similarity index 75% rename from mars/moons.css rename to src/assets/styles/mars/moons.css index e682070..d52ff9e 100644 --- a/mars/moons.css +++ b/src/assets/styles/mars/moons.css @@ -18,23 +18,20 @@ .moon-info strong{display:block;font-size:16px;letter-spacing:.08em}.moon-info p{margin:6px 0 0;font-size:11px;line-height:1.7;color:var(--muted)} .moon-sources{position:absolute;bottom:15px;left:20px;z-index:5;font-size:9px;color:var(--muted);max-width:65%;transition:opacity .08s ease,transform .08s ease,visibility 0s} .moon-sources a{margin-right:8px} -#mars-stage.moons-ready .planet-intro{top:75px;left:20px;transform:none;max-width:280px} -#mars-stage:not(.moon-closeup) .planet-intro.is-open{z-index:7} -#mars-stage:not(.moon-closeup):has(.planet-intro.is-open) :is(.moon-info,.moon-sources), -#mars-stage.moon-closeup:has(.moon-archive.is-open:not([hidden])) :is(.moon-info,.moon-sources){opacity:0;visibility:hidden;transform:translateY(5px);transition:opacity .08s ease,transform .08s ease,visibility 0s .08s} +#mars-stage.moons-ready .planet-intro{position:absolute;top:15px;left:20px;transform:none;width:min(280px,calc(50% - 155px));min-height:0;padding:14px 18px;transition:none} +#mars-stage :is(.intro-divider,.moon-archive-divider){display:none} #mars-stage.moon-closeup .planet-intro,#mars-stage.moons-ready .planet-hint{display:none} -.moon-archives{position:absolute;z-index:6;top:76px;left:20px;width:min(285px,calc(100% - 40px));pointer-events:auto} +.moon-archives{position:absolute;z-index:6;top:15px;left:20px;width:min(280px,calc(50% - 155px));pointer-events:auto} .moon-archive{box-sizing:border-box;padding:17px 18px;border:1px solid rgba(183,95,64,.28);border-radius:15px;background:rgba(250,247,240,.9);box-shadow:0 14px 34px -24px rgba(60,45,30,.55);backdrop-filter:blur(9px);color:#5a4d3d} .moon-archive-title{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;padding:0;border:0;background:transparent;cursor:pointer;text-align:left} .moon-archive-title span{color:var(--gold);font:700 10px var(--mono);letter-spacing:.14em;text-transform:uppercase}.moon-archive-mark{font-size:17px!important} .moon-archive-divider{height:1px;margin-top:12px;background:rgba(183,95,64,.32)} -.moon-archive:not(.is-open) .moon-archive-divider{display:none}.moon-archive.is-open{max-height:calc(100vh - 118px);overflow:auto;scrollbar-width:thin} +.moon-archive:not(.is-open) .moon-archive-divider{display:none} .moon-archive p{margin:11px 0 0;font-size:11px;line-height:1.8;color:var(--muted)}.moon-archive p a{color:var(--gold);text-underline-offset:3px} -@media(max-width:560px){.moon-archives{top:67px;left:12px;width:min(258px,calc(100% - 24px))}.moon-archive{padding:15px 16px}.moon-archive.is-open{max-height:calc(100vh - 108px)}.moon-archive p{font-size:10px;line-height:1.65}} -@media(max-width:560px){#mars-stage.moons-ready{height:610px;min-height:610px}.moon-toolbar{top:12px;max-width:95%}.moon-toolbar button{padding:9px 10px;font-size:10px}.moon-info{left:12px;right:12px;bottom:65px;max-width:none;padding:10px 13px}.moon-info strong{font-size:14px}.moon-sources{left:12px;bottom:14px;max-width:60%;line-height:1.6}#mars-stage.moons-ready .planet-intro{left:12px;top:68px;max-width:230px}} -/* Keep the title accessible while long profile copy scrolls inside the card. */ +/* Only the narrow archive grows; model bounds and camera framing stay fixed. */ #mars-stage.moons-ready .planet-intro.is-open,#mars-stage .moon-archive.is-open{display:flex;flex-direction:column;box-sizing:border-box;min-height:0;max-height:min(380px,calc(100dvh - 160px));overflow:hidden} -#mars-stage :is(.intro-title,.intro-divider,.moon-archive-title,.moon-archive-divider){flex-shrink:0} +#mars-stage :is(.intro-title,.moon-archive-title){flex-shrink:0} +#mars-stage :is(.intro-title,.moon-archive-title){min-height:24px} #mars-stage :is(#mars-intro-content,#phobos-archive-content,#deimos-archive-content){min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;touch-action:pan-y;scrollbar-width:thin;scrollbar-color:rgba(183,95,64,.65) transparent;scrollbar-gutter:stable;padding-right:8px;margin-top:10px} #mars-stage :is(#mars-intro-content,#phobos-archive-content,#deimos-archive-content)::-webkit-scrollbar{width:6px} #mars-stage :is(#mars-intro-content,#phobos-archive-content,#deimos-archive-content)::-webkit-scrollbar-thumb{background:rgba(183,95,64,.65);border-radius:8px} diff --git a/mars/pioneer.css b/src/assets/styles/mars/pioneer.css similarity index 100% rename from mars/pioneer.css rename to src/assets/styles/mars/pioneer.css diff --git a/mars/rovers.css b/src/assets/styles/mars/rovers.css similarity index 100% rename from mars/rovers.css rename to src/assets/styles/mars/rovers.css diff --git a/mars/site.css b/src/assets/styles/mars/site.css similarity index 100% rename from mars/site.css rename to src/assets/styles/mars/site.css diff --git a/mars/sky.css b/src/assets/styles/mars/sky.css similarity index 97% rename from mars/sky.css rename to src/assets/styles/mars/sky.css index c21b53c..0ff929d 100644 --- a/mars/sky.css +++ b/src/assets/styles/mars/sky.css @@ -1,7 +1,7 @@ .sky-panel .sky-toolbar { display:flex; justify-content:space-between; align-items:center; gap:12px; margin:15px 20px 0; color:var(--muted); font-size:11px; } .sky-toolbar i { font-style:normal; margin:0 7px; opacity:.5; } .sky-toolbar #sky-status { color:#a55335; font-weight:600; } -.sky-panel .sky-stage { background:linear-gradient(180deg,rgba(5,8,17,.05),rgba(5,8,17,.12)),url('../venus/milky-way-2mass.jpg') center / cover no-repeat,#090e1b; margin-top:12px; } +.sky-panel .sky-stage { background:linear-gradient(180deg,rgba(5,8,17,.05),rgba(5,8,17,.12)),url('../../images/venus/milky-way-2mass.jpg') center / cover no-repeat,#090e1b; margin-top:12px; } .sky-panel .sky-stage svg { width:100%; height:auto; aspect-ratio:12/5; } .sky-panel .skytext { font-size:11px; stroke:#090e1b; stroke-width:3px; } #sky-full-path { fill:none; stroke:#d8e7ed; stroke-width:1.6; stroke-dasharray:3 5; } diff --git a/delocalized configuration project/terminal.css b/src/assets/styles/terminal/terminal.css similarity index 100% rename from delocalized configuration project/terminal.css rename to src/assets/styles/terminal/terminal.css diff --git a/venus/site.css b/src/assets/styles/venus/site.css similarity index 100% rename from venus/site.css rename to src/assets/styles/venus/site.css diff --git a/venus/venus.css b/src/assets/styles/venus/venus.css similarity index 99% rename from venus/venus.css rename to src/assets/styles/venus/venus.css index a5de685..aeccb09 100644 --- a/venus/venus.css +++ b/src/assets/styles/venus/venus.css @@ -738,7 +738,7 @@ border-radius: 14px; background: linear-gradient(180deg, rgba(5, 8, 14, .14), rgba(4, 6, 11, .38)), - url("milky-way-2mass.jpg") center 48% / cover no-repeat, + url("../../images/venus/milky-way-2mass.jpg") center 48% / cover no-repeat, #080b12; box-shadow: inset 0 0 44px rgba(0, 0, 0, .36); } diff --git a/src/components/BackIcon.vue b/src/components/BackIcon.vue new file mode 100644 index 0000000..4b3d2b4 --- /dev/null +++ b/src/components/BackIcon.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/MusicButton.vue b/src/components/MusicButton.vue new file mode 100644 index 0000000..d10bd66 --- /dev/null +++ b/src/components/MusicButton.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/components/MusicPlayer.vue b/src/components/MusicPlayer.vue new file mode 100644 index 0000000..a3c02f1 --- /dev/null +++ b/src/components/MusicPlayer.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/composables/usePageScope.js b/src/composables/usePageScope.js new file mode 100644 index 0000000..7a49043 --- /dev/null +++ b/src/composables/usePageScope.js @@ -0,0 +1,92 @@ +import { onBeforeUnmount } from 'vue'; +import { useRouter } from 'vue-router'; + +// Each routed page owns its timers, observers, listeners and WebGL resources. +// Legacy astronomy modules receive this scope explicitly; browser APIs stay intact. +export function usePageScope() { + let active = true; + const cleanups = []; + const frames = new Set(); + const timeouts = new Set(); + const intervals = new Set(); + const controller = new AbortController(); + const onDispose = cleanup => active ? cleanups.push(cleanup) : cleanup(); + const scope = { + router: useRouter(), + get active() { return active; }, + onDispose, + listen(target, event, handler, options = {}) { + if (!active || !target) return; + target.addEventListener(event, handler, { + ...(typeof options === 'boolean' ? { capture: options } : options), + signal: controller.signal, + }); + }, + requestAnimationFrame(callback) { + if (!active) return 0; + const id = window.requestAnimationFrame(time => { + frames.delete(id); + if (active) callback(time); + }); + frames.add(id); + return id; + }, + cancelAnimationFrame(id) { frames.delete(id); window.cancelAnimationFrame(id); }, + setTimeout(callback, delay, ...args) { + if (!active) return 0; + const id = window.setTimeout(() => { + timeouts.delete(id); + if (active) callback(...args); + }, delay); + timeouts.add(id); + return id; + }, + clearTimeout(id) { timeouts.delete(id); window.clearTimeout(id); }, + setInterval(callback, delay, ...args) { + if (!active) return 0; + const id = window.setInterval(() => { if (active) callback(...args); }, delay); + intervals.add(id); + return id; + }, + clearInterval(id) { intervals.delete(id); window.clearInterval(id); }, + expose(name, value) { + if (!active) return value; + window[name] = value; + onDispose(() => { if (window[name] === value) delete window[name]; }); + return value; + }, + trackScene(scene, renderer, controls) { + onDispose(() => { + controls.dispose(); + const resources = new Set(); + scene.traverse(object => { + if (object.geometry) resources.add(object.geometry); + for (const material of [object.material].flat().filter(Boolean)) { + resources.add(material); + for (const value of Object.values(material)) if (value?.isTexture) resources.add(value); + } + }); + resources.forEach(resource => resource.dispose()); + renderer.dispose(); + renderer.forceContextLoss(); + }); + }, + }; + for (const name of ['ResizeObserver', 'IntersectionObserver', 'PerformanceObserver']) { + scope[name] = function(callback, options) { + const observer = new window[name]((...args) => { if (active) callback(...args); }, options); + onDispose(() => observer.disconnect()); + return observer; + }; + } + onBeforeUnmount(() => { + active = false; + controller.abort(); + frames.forEach(id => window.cancelAnimationFrame(id)); + timeouts.forEach(id => window.clearTimeout(id)); + intervals.forEach(id => window.clearInterval(id)); + cleanups.reverse().forEach(cleanup => cleanup()); + frames.clear(); timeouts.clear(); intervals.clear(); + }); + return scope; +} diff --git a/src/composables/usePlanetPreview.js b/src/composables/usePlanetPreview.js new file mode 100644 index 0000000..5c69c80 --- /dev/null +++ b/src/composables/usePlanetPreview.js @@ -0,0 +1,38 @@ +import { onMounted, onBeforeUnmount } from 'vue'; +import { mountVenus } from '../scripts/venus/model.js'; + +export function usePlanetPreview(planet) { + let observer; + let cleanup; + let started = false; + let disposed = false; + onMounted(() => { + const section = document.getElementById(planet); + const stage = document.getElementById(`${planet}-preview-stage`); + const mount = document.getElementById(`${planet}-preview-canvas`); + async function start() { + if (started || disposed) return; + started = true; + const dispose = await mountVenus(stage, mount, { + preview: true, + surface: planet === 'mars' ? 'images/mars/mars-texture.jpg' : 'images/venus/venus-surface.jpg', + tilt: planet === 'mars' ? 25.19 : 177.4, + }); + if (disposed) dispose?.(); + else cleanup = dispose; + } + if ('IntersectionObserver' in window) { + observer = new IntersectionObserver(([entry]) => { + if (!entry.isIntersecting) return; + observer.disconnect(); + start(); + }, { rootMargin: '240px 0px' }); + observer.observe(section); + } else start(); + }); + onBeforeUnmount(() => { + disposed = true; + observer?.disconnect(); + cleanup?.(); + }); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..b9bfc4c --- /dev/null +++ b/src/main.js @@ -0,0 +1,12 @@ +import { createApp, createSSRApp } from 'vue'; +import App from './App.vue'; +import { createSiteRouter } from './router/index.js'; +import './assets/main.css'; + +document.documentElement.classList.add('js'); +const container = document.getElementById('website-app'); +const app = container.innerHTML.trim() ? createSSRApp(App) : createApp(App); +const router = createSiteRouter(); +app.use(router); +await router.isReady(); +app.mount(container); diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..e78c225 --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,37 @@ +import { nextTick } from 'vue'; +import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'; +import { pages, updateSeo } from '../seo.js'; + +export const routes = [ + { path: pages.home.path, alias: '/index.html', name: 'home', component: () => import('../views/Home.vue'), meta: pages.home }, + { path: pages.venus.path, alias: '/venus/index.html', name: 'venus', component: () => import('../views/Venus.vue'), meta: pages.venus }, + { path: pages.mars.path, alias: '/mars/index.html', name: 'mars', component: () => import('../views/Mars.vue'), meta: pages.mars }, + { path: pages.terminal.path, alias: '/delocalized%20configuration%20project/index.html', name: 'terminal', component: () => import('../views/Terminal.vue'), meta: pages.terminal }, + { path: '/:pathMatch(.*)*', redirect: '/' }, +]; + +export function createSiteRouter({ server = false } = {}) { + const router = createRouter({ + history: server ? createMemoryHistory() : createWebHistory(import.meta.env.BASE_URL), + routes, + async scrollBehavior(to, from, savedPosition) { + await nextTick(); + if (savedPosition) return savedPosition; + if (to.hash) { + if (to.name === 'mars' && to.hash.startsWith('#rovers/')) return false; + const el = document.getElementById(decodeURIComponent(to.hash.slice(1))); + return el ? { el } : false; + } + return to.name === from.name ? false : { top: 0 }; + }, + }); + if (!server) router.afterEach((to, from) => { + updateSeo(to.meta); + document.documentElement.dataset.page = to.name; + document.body.dataset.page = to.name; + if (to.name !== from.name) { + document.documentElement.classList.remove('site-menu-open', 'dc-navigation-active', 'music-playing'); + } + }); + return router; +} diff --git a/app.js b/src/scripts/home/app.js similarity index 86% rename from app.js rename to src/scripts/home/app.js index 9573d36..034d8f9 100644 --- a/app.js +++ b/src/scripts/home/app.js @@ -1,3 +1,5 @@ + +export function initHome(scope) { const loading = document.getElementById('loading'); const main = document.getElementById('main'); const bar = document.getElementById('bar'); @@ -14,6 +16,7 @@ function setProgress(p) { } function finish() { + if (!scope.active) return; if (finished) return; finished = true; setProgress(100); @@ -27,13 +30,13 @@ function finish() { target?.scrollIntoView({ behavior: 'instant', block: 'start' }); // The browser may process the fragment while the main content is still // hidden. Reapply it on the next frame once its final viewport height exists. - requestAnimationFrame(() => target?.scrollIntoView({ behavior: 'instant', block: 'start' })); + scope.requestAnimationFrame(() => target?.scrollIntoView({ behavior: 'instant', block: 'start' })); } window.dispatchEvent(new Event('site-ready')); } function wait(ms) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); + return new Promise((resolve) => scope.setTimeout(resolve, ms)); } // The title font is local but can still arrive after first layout on a new @@ -63,7 +66,7 @@ function setupReveal() { items.forEach(show); } else { // 一次性淡入:进入视口后保持显示 - const io = new IntersectionObserver( + const io = new scope.IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { @@ -108,8 +111,8 @@ function setupScrollFade() { }); }; - window.addEventListener('scroll', update, { passive: true }); - window.addEventListener('resize', update); + scope.listen(window, 'scroll', update, { passive: true }); + scope.listen(window, 'resize', update); update(); } @@ -121,7 +124,7 @@ function setupEnter() { const states = new Map(); sections.forEach((el) => states.set(el, false)); - const io = new IntersectionObserver( + const io = new scope.IntersectionObserver( (entries) => { entries.forEach((entry) => { const el = entry.target; @@ -177,18 +180,18 @@ function refresh() { // Keep counting resources that finish after this script runs (images, // fonts, anything fetched later), and update the bar as they arrive. try { - const observer = new PerformanceObserver(() => refresh()); + const observer = new scope.PerformanceObserver(() => refresh()); observer.observe({ type: 'resource', buffered: true }); } catch (e) { /* PerformanceObserver unsupported: fall back to lifecycle */ } -document.addEventListener('DOMContentLoaded', () => { +scope.listen(document, 'DOMContentLoaded', () => { refresh(); // Start once markup and the critical title font are ready. Images and other // non-critical resources continue downloading in the background. scheduleFinish(); }); -window.addEventListener('load', refresh); +scope.listen(window, 'load', refresh); // If the DOM was already parsed when this runs (very fast/local case), // start the transition immediately. @@ -198,23 +201,16 @@ if (document.readyState !== 'loading') { } // Public hook: report in later from async work (data fetches, media). -window.siteLoader = { setProgress, finish }; - -// Directory URLs are canonical on the website; file previews need an HTML file. -if (location.protocol === 'file:') { - document.getElementById('go-terminal').href = './delocalized%20configuration%20project/index.html'; - document.getElementById('go-venus').href = './venus/index.html'; - document.getElementById('go-mars').href = './mars/index.html'; -} +scope.expose('siteLoader', { setProgress, finish }); -// DC 是一个独立页面。点击入口时先显示极短的传输过渡,避免页面直接切换; +// 切换项目路由前显示传输过渡; // 修饰键/非主键点击保留浏览器原生的新标签页和菜单行为。 const projectLinks = document.querySelectorAll('#go-terminal, #go-venus, #go-mars'); const dcPageTransition = document.getElementById('dc-page-transition'); if (projectLinks.length && dcPageTransition) { let dcNavigationPending = false; - projectLinks.forEach((projectLink) => projectLink.addEventListener('click', (event) => { + projectLinks.forEach((projectLink) => scope.listen(projectLink, 'click', (event) => { if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } @@ -230,18 +226,18 @@ if (projectLinks.length && dcPageTransition) { document.documentElement.classList.add('dc-navigation-active'); window.dispatchEvent(new Event('site-navigation')); - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { + scope.requestAnimationFrame(() => { + scope.requestAnimationFrame(() => { dcPageTransition.classList.add('is-active'); - window.setTimeout(() => { - window.location.assign(projectLink.href); + scope.setTimeout(() => { + scope.router.push(new URL(projectLink.href).pathname); }, 1000); }); }); })); // 恢复前进/后退缓存页面时确保遮罩不会残留。 - window.addEventListener('pageshow', () => { + scope.listen(window, 'pageshow', () => { dcNavigationPending = false; dcPageTransition.classList.remove('is-mounted'); dcPageTransition.classList.remove('is-active'); @@ -258,7 +254,8 @@ document.querySelectorAll('.contact-circle[data-email]').forEach((link) => { }); // 占位的联系按钮(href="#")点击时不跳回顶部;替换 href 后即为可用链接 -document.addEventListener('click', (e) => { +scope.listen(document, 'click', (e) => { const placeholder = e.target.closest('.contact-circle[data-pending]'); if (placeholder) e.preventDefault(); }); +} diff --git a/navigation.js b/src/scripts/home/navigation.js similarity index 78% rename from navigation.js rename to src/scripts/home/navigation.js index 6db3f73..139a7aa 100644 --- a/navigation.js +++ b/src/scripts/home/navigation.js @@ -1,3 +1,5 @@ + +export function initNavigation(scope) { (() => { const toggle = document.getElementById('site-nav-toggle'); const closeButton = document.getElementById('site-nav-close'); @@ -11,16 +13,16 @@ let idleTimer; function revealToggle() { - clearTimeout(idleTimer); + scope.clearTimeout(idleTimer); toggle.classList.remove('is-idle-hidden'); if (toggle.hidden || menu.open) return; - idleTimer = window.setTimeout(() => toggle.classList.add('is-idle-hidden'), 2000); + idleTimer = scope.setTimeout(() => toggle.classList.add('is-idle-hidden'), 2000); } // Physical input renews the timer; automatic scroll and rendering do not. ['pointermove', 'pointerdown', 'touchstart', 'keydown'].forEach(type => { - document.addEventListener(type, revealToggle, { passive: true }); + scope.listen(document, type, revealToggle, { passive: true }); }); - toggle.addEventListener('focus', revealToggle); + scope.listen(toggle, 'focus', revealToggle); function updateToggleContrast() { if (toggle.hidden || menu.open) return; @@ -61,31 +63,31 @@ updateToggleContrast(); } - toggle.addEventListener('click', () => { + scope.listen(toggle, 'click', () => { updateCurrent(); menu.showModal(); - clearTimeout(idleTimer); + scope.clearTimeout(idleTimer); toggle.setAttribute('aria-expanded', 'true'); document.documentElement.classList.add('site-menu-open'); closeButton.focus({ preventScroll: true }); window.dispatchEvent(new Event('idle-controls-reset')); }); - closeButton.addEventListener('click', closeMenu); - menu.addEventListener('close', closeMenu); - menu.addEventListener('cancel', event => { + scope.listen(closeButton, 'click', closeMenu); + scope.listen(menu, 'close', closeMenu); + scope.listen(menu, 'cancel', event => { event.preventDefault(); closeMenu(); }); - links.forEach((link, i) => link.addEventListener('click', event => { + links.forEach((link, i) => scope.listen(link, 'click', event => { if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); closeMenu(); - if (location.hash !== link.hash) history.pushState(null, '', link.hash); + if (location.hash !== link.hash) scope.router.push({ hash: link.hash }); const section = sections[i]; section.focus({ preventScroll: true }); // Wait until the dialog has released the mobile viewport, then calculate // an absolute target. This keeps the section heading at the real top. - requestAnimationFrame(() => { + scope.requestAnimationFrame(() => { const top = Math.max(0, Math.round(scrollY + section.getBoundingClientRect().top)); window.scrollTo({ top, behavior: reducedMotion.matches ? 'instant' : 'smooth' }); }); @@ -94,9 +96,10 @@ // The main content is initially hidden. Show the menu only once its targets exist on screen. const ready = () => { toggle.hidden = false; updateToggleContrast(); revealToggle(); }; if (!main.hidden) ready(); - window.addEventListener('site-ready', ready, { once: true }); - window.addEventListener('pagehide', () => { closeMenu(); clearTimeout(idleTimer); }); - window.addEventListener('pageshow', closeMenu); - window.addEventListener('scroll', updateToggleContrast, { passive: true }); - window.addEventListener('resize', updateToggleContrast); + scope.listen(window, 'site-ready', ready, { once: true }); + scope.listen(window, 'pagehide', () => { closeMenu(); scope.clearTimeout(idleTimer); }); + scope.listen(window, 'pageshow', closeMenu); + scope.listen(window, 'scroll', updateToggleContrast, { passive: true }); + scope.listen(window, 'resize', updateToggleContrast); })(); +} diff --git a/mars/moons.js b/src/scripts/mars/moons.js similarity index 93% rename from mars/moons.js rename to src/scripts/mars/moons.js index 8aeee76..248639d 100644 --- a/mars/moons.js +++ b/src/scripts/mars/moons.js @@ -1,7 +1,9 @@ + +export function initMarsMoons(scope) { /* NASA/JPL shape meshes, normalized by volume to JPL equivalent mean radii. One scene unit = 3390 / 1.34 km. Orbital phases illustrate a configuration, not a dated ephemeris. Orbit curves use JPL mean a and e. */ -window.setupMarsMoons=function({scene,camera,renderer,controls,world,sphere,stage,mount,surfaceMaterialOptions}) { +scope.expose('setupMarsMoons', function({scene,camera,renderer,controls,world,sphere,stage,mount,surfaceMaterialOptions}) { const T=THREE,unit=1.34/3390,reduced=matchMedia('(prefers-reduced-motion: reduce)'); const rotationButton=document.getElementById('rotation-toggle'); const bodies={mars:{name:'火星',en:'MARS',radius:3390,mesh:sphere,color:'#c5764e',text:'平均半径 3,390 km · 拖动旋转,滚轮缩放'}, @@ -156,11 +158,12 @@ window.setupMarsMoons=function({scene,camera,renderer,controls,world,sphere,stag } } let down=null; - renderer.domElement.addEventListener('pointerdown',e=>{down={x:e.clientX,y:e.clientY};}); - renderer.domElement.addEventListener('pointerup',e=>{if(!down||Math.hypot(e.clientX-down.x,e.clientY-down.y)>5||flight)return;const r=renderer.domElement.getBoundingClientRect();ray.setFromCamera(new T.Vector2((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1),camera);const hit=ray.intersectObjects(Object.values(bodies).map(b=>b.mesh))[0];if(hit){const id=Object.keys(bodies).find(id=>bodies[id].mesh===hit.object);if(id!==selected)focus(id);}}); + scope.listen(renderer.domElement, 'pointerdown',e=>{down={x:e.clientX,y:e.clientY};}); + scope.listen(renderer.domElement, 'pointerup',e=>{if(!down||Math.hypot(e.clientX-down.x,e.clientY-down.y)>5||flight)return;const r=renderer.domElement.getBoundingClientRect();ray.setFromCamera(new T.Vector2((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1),camera);const hit=ray.intersectObjects(Object.values(bodies).map(b=>b.mesh))[0];if(hit){const id=Object.keys(bodies).find(id=>bodies[id].mesh===hit.object);if(id!==selected)focus(id);}}); let resizeTimer; - new ResizeObserver(()=>{clearTimeout(resizeTimer);resizeTimer=setTimeout(()=>{if(mount.clientWidth&&mount.clientHeight)focus(selected,true);},80);}).observe(mount); - window.MarsMoons={bodies,focus,camera,controls,get selected(){return selected;},get transitioning(){return !!flight;},unit}; + new scope.ResizeObserver(()=>{scope.clearTimeout(resizeTimer);resizeTimer=scope.setTimeout(()=>{if(mount.clientWidth&&mount.clientHeight)focus(selected,true);},80);}).observe(mount); + scope.expose('MarsMoons', {bodies,focus,camera,controls,get selected(){return selected;},get transitioning(){return !!flight;},unit}); focus('system',true); return {beforeFrame,afterFrame}; -}; +}); +} diff --git a/mars/music.js b/src/scripts/mars/music.js similarity index 77% rename from mars/music.js rename to src/scripts/mars/music.js index 349afb0..639f2e5 100644 --- a/mars/music.js +++ b/src/scripts/mars/music.js @@ -1,3 +1,5 @@ + +export function initMarsMusic(scope) { (() => { const audio = document.getElementById('bg-music'); if (!audio) return; @@ -24,13 +26,23 @@ let next = partner; let enabled = get(preferenceKey) === '1'; let playing = false; + let starting = false; let resumePending = false; let firstStart = true; let crossfading = false; let volumeFrame = 0; let crossfadeFrame = 0; + scope.onDispose(() => { + savePosition(); + playing = false; + current.pause(); + next.pause(); + partner.remove(); + document.documentElement.classList.remove('music-playing'); + }); function updateIcon() { + if (!scope.active) return; document.documentElement.classList.toggle('music-playing', playing); toggle?.setAttribute('aria-pressed', String(playing)); } @@ -51,14 +63,14 @@ try { current.currentTime = Math.min(saved, safeEnd); } catch {} }; if (current.readyState) apply(); - else current.addEventListener('loadedmetadata', apply, { once: true }); + else scope.listen(current, 'loadedmetadata', apply, { once: true }); const syncPartner = () => { try { next.currentTime = current.currentTime; } catch {} }; if (next.readyState) syncPartner(); - else next.addEventListener('loadedmetadata', syncPartner, { once: true }); + else scope.listen(next, 'loadedmetadata', syncPartner, { once: true }); } function fadeVolume(element, to, seconds) { - cancelAnimationFrame(volumeFrame); + scope.cancelAnimationFrame(volumeFrame); const from = element.volume; const start = performance.now(); const duration = Math.max(1, seconds * 1000); @@ -66,9 +78,9 @@ const progress = Math.min(1, (now - start) / duration); const eased = progress * progress * (3 - 2 * progress); element.volume = from + (to - from) * eased; - if (progress < 1 && playing && !crossfading) volumeFrame = requestAnimationFrame(step); + if (progress < 1 && playing && !crossfading) volumeFrame = scope.requestAnimationFrame(step); }; - volumeFrame = requestAnimationFrame(step); + volumeFrame = scope.requestAnimationFrame(step); } function finishCrossfade() { @@ -83,13 +95,14 @@ async function startCrossfade() { if (!playing || crossfading || !Number.isFinite(current.duration)) return; crossfading = true; - cancelAnimationFrame(volumeFrame); + scope.cancelAnimationFrame(volumeFrame); next.preload = 'auto'; next.muted = false; next.volume = 0; try { next.currentTime = 0; } catch {} if (next.paused) try { await next.play(); + if (!scope.active) { current.pause(); next.pause(); return; } } catch { crossfading = false; current.volume = targetVolume; @@ -105,10 +118,10 @@ const angle = progress * Math.PI / 2; current.volume = targetVolume * Math.cos(angle); next.volume = targetVolume * Math.sin(angle); - if (progress < 1) crossfadeFrame = requestAnimationFrame(mix); + if (progress < 1) crossfadeFrame = scope.requestAnimationFrame(mix); else finishCrossfade(); }; - crossfadeFrame = requestAnimationFrame(mix); + crossfadeFrame = scope.requestAnimationFrame(mix); } function monitorLoop() { @@ -122,8 +135,8 @@ playing = false; resumePending = false; crossfading = false; - cancelAnimationFrame(volumeFrame); - cancelAnimationFrame(crossfadeFrame); + scope.cancelAnimationFrame(volumeFrame); + scope.cancelAnimationFrame(crossfadeFrame); current.pause(); next.pause(); current.volume = 0; @@ -133,7 +146,8 @@ } async function startPlayback({ restore = true } = {}) { - if (playing) return; + if (playing || starting || !scope.active) return; + starting = true; current.muted = false; next.muted = false; current.volume = 0; @@ -142,6 +156,7 @@ if (restore) restorePosition(); try { await Promise.all([current.play(), next.play()]); + if (!scope.active) { current.pause(); next.pause(); return; } playing = true; resumePending = false; updateIcon(); @@ -152,10 +167,12 @@ next.pause(); resumePending = enabled; updateIcon(); + } finally { + starting = false; } } - toggle?.addEventListener('click', () => { + scope.listen(toggle, 'click', () => { if (!playing) { enabled = true; set(preferenceKey, '1'); @@ -167,27 +184,30 @@ } }); - ['pointerdown', 'keydown'].forEach(type => document.addEventListener(type, event => { + ['pointerdown', 'keydown'].forEach(type => scope.listen(document, type, event => { if (resumePending && !toggle?.contains(event.target)) startPlayback(); }, { passive: true })); - setInterval(() => { + scope.setInterval(() => { monitorLoop(); if (playing) savePosition(); }, 200); - window.addEventListener('pagehide', () => { + scope.listen(window, 'pagehide', () => { savePosition(); playing = false; crossfading = false; - cancelAnimationFrame(volumeFrame); - cancelAnimationFrame(crossfadeFrame); + scope.cancelAnimationFrame(volumeFrame); + scope.cancelAnimationFrame(crossfadeFrame); current.pause(); next.pause(); }); - window.addEventListener('pageshow', () => { + scope.listen(window, 'pageshow', () => { enabled = get(preferenceKey) === '1'; if (enabled) startPlayback(); }); + // SPA route entry does not emit the browser's pageshow event. + if (enabled) startPlayback(); })(); +} diff --git a/mars/pioneer.js b/src/scripts/mars/pioneer.js similarity index 88% rename from mars/pioneer.js rename to src/scripts/mars/pioneer.js index 9069205..b3b5795 100644 --- a/mars/pioneer.js +++ b/src/scripts/mars/pioneer.js @@ -1,3 +1,5 @@ + +export function initPioneer(scope) { (function(){ 'use strict'; const buttons=[...document.querySelectorAll('.pioneer-tabs [role="tab"]')]; @@ -10,7 +12,7 @@ panel.hidden=!active; if(active){ panel.classList.remove('pioneer-content-enter'); - requestAnimationFrame(()=>panel.classList.add('pioneer-content-enter')); + scope.requestAnimationFrame(()=>panel.classList.add('pioneer-content-enter')); } }); if(focus)buttons[index].focus(); @@ -28,3 +30,4 @@ }; }); })(); +} diff --git a/mars/planet.js b/src/scripts/mars/planet.js similarity index 89% rename from mars/planet.js rename to src/scripts/mars/planet.js index a4f1b45..fef01ca 100644 --- a/mars/planet.js +++ b/src/scripts/mars/planet.js @@ -1,3 +1,5 @@ + +export function initMarsPlanet(scope) { (function(){ const stage = document.getElementById('mars-stage'); @@ -25,7 +27,7 @@ let revealFrame = 0; let revealRequested = false; const setModelView = active => { - cancelAnimationFrame(revealFrame); + scope.cancelAnimationFrame(revealFrame); revealRequested = active && modelLoaded; stage.classList.toggle('model-view-leaving', !active && modelLoaded); stage.classList.remove('model-view-active'); @@ -38,11 +40,11 @@ } // Reveal only after a textured frame has actually reached the canvas. }; - window.MarsModelView = { + scope.expose('MarsModelView', { enter: () => setModelView(true), exit: () => setModelView(false), get loaded(){ return modelLoaded; } - }; + }); const world = new THREE.Group(); world.rotation.z = THREE.MathUtils.degToRad(25.19); @@ -68,6 +70,7 @@ const texture = new THREE.TextureLoader().load( textureSource, map => { + if (!scope.active) { map.dispose(); return; } map.colorSpace = THREE.SRGBColorSpace; map.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 4); material.map = map; @@ -83,6 +86,7 @@ () => stage.classList.add('is-error') ); texture.colorSpace = THREE.SRGBColorSpace; + scope.onDispose(() => texture.dispose()); const atmosphere = new THREE.Mesh( new THREE.SphereGeometry(1.355, 40, 40), @@ -138,6 +142,7 @@ scene.add(rimLight); const controls = new OrbitControls(camera, renderer.domElement); + scope.trackScene(scene, renderer, controls); controls.enableDamping = true; controls.dampingFactor = .075; controls.enablePan = false; @@ -162,7 +167,7 @@ camera.fov = camera.aspect < 1 ? THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(19)) / camera.aspect)) : 38; camera.updateProjectionMatrix(); }; - const resizeObserver = new ResizeObserver(resize); + const resizeObserver = new scope.ResizeObserver(resize); resizeObserver.observe(mount); resize(); @@ -173,7 +178,7 @@ const frameInterval = 1000 / 40; const animate = time => { if (!isRunning) return; - animationFrame = requestAnimationFrame(animate); + animationFrame = scope.requestAnimationFrame(animate); if (time - lastRenderTime < frameInterval) return; lastRenderTime = time; moons.beforeFrame(time); @@ -182,8 +187,8 @@ moons.afterFrame(); if (revealRequested && !stage.hidden) { revealRequested = false; - revealFrame = requestAnimationFrame(() => { - revealFrame = requestAnimationFrame(() => stage.classList.add('model-view-active')); + revealFrame = scope.requestAnimationFrame(() => { + revealFrame = scope.requestAnimationFrame(() => stage.classList.add('model-view-active')); }); } }; @@ -192,19 +197,19 @@ const shouldRun = isInView && !document.hidden; if (shouldRun && !isRunning) { isRunning = true; - animationFrame = requestAnimationFrame(animate); + animationFrame = scope.requestAnimationFrame(animate); } else if (!shouldRun && isRunning) { isRunning = false; - cancelAnimationFrame(animationFrame); + scope.cancelAnimationFrame(animationFrame); } }; - const visibilityObserver = new IntersectionObserver(([entry]) => { + const visibilityObserver = new scope.IntersectionObserver(([entry]) => { isInView = entry.isIntersecting; syncAnimation(); }, { rootMargin: '120px 0px' }); visibilityObserver.observe(stage); - document.addEventListener('visibilitychange', syncAnimation); + scope.listen(document, 'visibilitychange', syncAnimation); syncAnimation(); } catch (error) { console.error('Mars 3D model failed to initialize:', error); @@ -212,3 +217,4 @@ } })(); +} diff --git a/mars/rover-data.js b/src/scripts/mars/rover-data.js similarity index 99% rename from mars/rover-data.js rename to src/scripts/mars/rover-data.js index ce86b67..5b77af3 100644 --- a/mars/rover-data.js +++ b/src/scripts/mars/rover-data.js @@ -1,4 +1,6 @@ -window.MARS_ROVERS = [ + +export function initRoverData(scope) { +scope.expose('MARS_ROVERS', [ { "id": "perseverance", "photo": {"file":"perseverance-surface.jpg","title":"切亚瓦瀑布旁的自拍","note":"2024 年 7 月 23 日,毅力号在杰泽罗陨石坑拍摄的自拍,由机械臂相机的 62 张实拍影像拼接。","credit":"NASA/JPL-Caltech/MSSS","source":"https://science.nasa.gov/resource/perseverances-selfie-with-cheyava-falls/"}, @@ -266,4 +268,5 @@ window.MARS_ROVERS = [ ] ] } -]; +]); +} diff --git a/mars/rovers.js b/src/scripts/mars/rovers.js similarity index 87% rename from mars/rovers.js rename to src/scripts/mars/rovers.js index 9e8b177..3b19cda 100644 --- a/mars/rovers.js +++ b/src/scripts/mars/rovers.js @@ -1,3 +1,6 @@ +import { assetUrl } from '../../utils/assets.js'; + +export function initRovers(scope) { (function(){ 'use strict'; const data=window.MARS_ROVERS; @@ -19,7 +22,7 @@ const markers=document.createElement('div');markers.className='route-markers';viewport.append(markers); const placeStatus=document.createElement('span');placeStatus.className='route-place-status';placeStatus.setAttribute('role','status');placeStatus.hidden=true;viewport.append(placeStatus); const number=i=>String(i+1).padStart(2,'0'); - function stopMotion(){cancelAnimationFrame(motion);motion=0;} + function stopMotion(){scope.cancelAnimationFrame(motion);motion=0;} function renderMarkers(){ markers.innerHTML=selected.waypoints.map((p,i)=>detailMap&&!p.detail?'':'').join(''); markers.querySelectorAll('button').forEach(b=>b.onclick=()=>focusPlace(Number(b.dataset.place))); @@ -34,7 +37,7 @@ $('rover-map-source').href=overview?overview.source:selected.mapSource; $('route-loading').hidden=false;viewport.setAttribute('aria-busy','true'); routeImage.alt=selected.name+':'+(overview?overview.note:selected.routeTitle); - routeImage.src='assets/rovers/'+(overview?overview.file:selected.id+'.jpg');renderMarkers();paintImage(); + routeImage.src=assetUrl('images/mars/rovers/'+(overview?overview.file:selected.id+'.jpg'));renderMarkers();paintImage(); } function focusPlace(index){ const point=selected.waypoints[index];if(!point)return; @@ -49,8 +52,8 @@ const x=Math.max(-limitX,Math.min(limitX,(.5-p.x)*routeImage.naturalWidth*fit*target)); const y=Math.max(-limitY,Math.min(limitY,(.5-p.y)*routeImage.naturalHeight*fit*target)); const start={scale,x:panX,y:panY},began=performance.now(); - function frame(now){const t=reducedMotion.matches?1:Math.min(1,(now-began)/700),ease=1-Math.pow(1-t,3);scale=start.scale+(target-start.scale)*ease;panX=start.x+(x-start.x)*ease;panY=start.y+(y-start.y)*ease;paintImage();if(t<1)motion=requestAnimationFrame(frame);else motion=0;} - motion=requestAnimationFrame(frame); + function frame(now){const t=reducedMotion.matches?1:Math.min(1,(now-began)/700),ease=1-Math.pow(1-t,3);scale=start.scale+(target-start.scale)*ease;panX=start.x+(x-start.x)*ease;panY=start.y+(y-start.y)*ease;paintImage();if(t<1)motion=scope.requestAnimationFrame(frame);else motion=0;} + motion=scope.requestAnimationFrame(frame); } let tabTransition=0; function showTab(index,focus){ @@ -71,16 +74,16 @@ if(focus)tabButtons[index].focus(); if(current===1&&window.MarsModelView)window.MarsModelView.exit(); if(previous){previous.classList.remove('tab-view-enter');previous.classList.add('tab-view-exit');} - setTimeout(()=>{ + scope.setTimeout(()=>{ if(token!==tabTransition)return; views.forEach((view,i)=>{ const element=$(view.id),active=i===index; element.hidden=!active; element.classList.remove('tab-view-enter','tab-view-exit'); - if(active)requestAnimationFrame(()=>element.classList.add('tab-view-enter')); + if(active)scope.requestAnimationFrame(()=>element.classList.add('tab-view-enter')); }); if(index===1&&window.MarsModelView)window.MarsModelView.enter(); - if(index===2)requestAnimationFrame(centerSelected); + if(index===2)scope.requestAnimationFrame(centerSelected); },delay); } tabButtons.forEach((button,index)=>{ @@ -98,7 +101,7 @@ for(let lon=-120;lon<=120;lon+=60){const x=(lon+180)/360*1000;grid+=''+(lon<0?-lon+'°W':lon>0?lon+'°E':'0°')+'';} for(const lat of [-60,-30,0,30,60]){const y=(90-lat)/180*500;grid+=''+(lat<0?-lat+'°S':lat>0?lat+'°N':'赤道 0°')+'';} $('rover-graticule').innerHTML=grid; - document.querySelectorAll('.rover-selector').forEach(b=>b.onclick=()=>{++navigation;selectRover(b.dataset.id);if(location.hash.startsWith('#rovers/'))history.replaceState(null,'','#rovers/'+b.dataset.id);}); + document.querySelectorAll('.rover-selector').forEach(b=>b.onclick=()=>{++navigation;selectRover(b.dataset.id);if(location.hash.startsWith('#rovers/'))scope.router.replace({hash:'#rovers/'+b.dataset.id});}); document.querySelectorAll('.rover-pin').forEach(b=>b.onclick=()=>navigateRover(b.dataset.id)); function paintImage(){ const w=viewport.clientWidth,h=viewport.clientHeight; @@ -141,7 +144,7 @@ if(hasSurfacePhoto&&photo){ $('rover-surface-title').textContent=photo.title; $('rover-surface-image').alt=rover.name+' · '+photo.title; - $('rover-surface-image').src='assets/rovers/'+photo.file; + $('rover-surface-image').src=assetUrl('images/mars/rovers/'+photo.file); $('rover-surface-note').textContent=photo.note; $('rover-surface-source').textContent=photo.credit+' · 图片来源 ↗'; $('rover-surface-source').href=photo.source; @@ -149,7 +152,7 @@ } if(hasSurfacePhoto)$('rover-surface-expand').closest('figure').hidden=!photo; $('rover-concept-image').alt=rover.name+':'+rover.concept.note; - $('rover-concept-image').src='assets/rovers/'+rover.concept.file; + $('rover-concept-image').src=assetUrl('images/mars/rovers/'+rover.concept.file); $('rover-concept-note').textContent=rover.concept.note; $('rover-concept-source').textContent=rover.concept.credit+' · 图片来源 ↗'; $('rover-concept-source').href=rover.concept.source; @@ -163,7 +166,7 @@ $('rover-latest-map').hidden=!rover.latest;if(rover.latest)$('rover-latest-map').href=rover.latest;else $('rover-latest-map').removeAttribute('href'); document.querySelectorAll('.rover-overview,.rover-detail,.rover-footnote').forEach(element=>{ element.classList.remove('rover-content-enter'); - requestAnimationFrame(()=>element.classList.add('rover-content-enter')); + scope.requestAnimationFrame(()=>element.classList.add('rover-content-enter')); }); scale=1;panX=panY=0;drag=null;viewport.classList.remove('is-dragging'); $('route-loading').hidden=false;$('route-loading').textContent='正在载入路线图…';viewport.setAttribute('aria-busy','true'); @@ -174,24 +177,24 @@ routeImage.onerror=()=>{$('route-loading').hidden=false;$('route-loading').textContent='路线图暂时无法载入,请使用下方“原始地图”链接查看。';viewport.setAttribute('aria-busy','false');}; function resetRoute(){zoom(1);if(detailMap){detailMap=false;loadRoute();}} $('route-zoom-in').onclick=()=>zoom(scale+.5);$('route-zoom-out').onclick=()=>zoom(scale-.5);$('route-fit').onclick=resetRoute; - viewport.addEventListener('wheel',e=>{ + scope.listen(viewport, 'wheel',e=>{ if(!routeImage.naturalWidth)return; e.preventDefault(); const rect=viewport.getBoundingClientRect(); const delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?rect.height:1); zoom(scale*Math.exp(-delta*.0015),{x:e.clientX-rect.left-rect.width/2,y:e.clientY-rect.top-rect.height/2}); },{passive:false}); - viewport.addEventListener('pointerdown',e=>{if(e.target.closest('button')||scale<=1||e.button!==0)return;stopMotion();drag={id:e.pointerId,x:e.clientX,y:e.clientY,panX,panY};viewport.setPointerCapture(e.pointerId);viewport.classList.add('is-dragging');e.preventDefault();}); - viewport.addEventListener('pointermove',e=>{if(!drag||e.pointerId!==drag.id)return;panX=drag.panX+e.clientX-drag.x;panY=drag.panY+e.clientY-drag.y;paintImage();}); + scope.listen(viewport, 'pointerdown',e=>{if(e.target.closest('button')||scale<=1||e.button!==0)return;stopMotion();drag={id:e.pointerId,x:e.clientX,y:e.clientY,panX,panY};viewport.setPointerCapture(e.pointerId);viewport.classList.add('is-dragging');e.preventDefault();}); + scope.listen(viewport, 'pointermove',e=>{if(!drag||e.pointerId!==drag.id)return;panX=drag.panX+e.clientX-drag.x;panY=drag.panY+e.clientY-drag.y;paintImage();}); function stopDrag(){drag=null;viewport.classList.remove('is-dragging');} - viewport.addEventListener('pointerup',stopDrag);viewport.addEventListener('pointercancel',stopDrag);viewport.addEventListener('lostpointercapture',stopDrag); - viewport.addEventListener('keydown',e=>{ + scope.listen(viewport, 'pointerup',stopDrag);scope.listen(viewport, 'pointercancel',stopDrag);scope.listen(viewport, 'lostpointercapture',stopDrag); + scope.listen(viewport, 'keydown',e=>{ if(e.key==='+'||e.key==='='){e.preventDefault();zoom(scale+.5);} else if(e.key==='-'){e.preventDefault();zoom(scale-.5);} else if(e.key==='0'){e.preventDefault();resetRoute();} else if(scale>1&&['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();stopMotion();panX+=e.key==='ArrowLeft'?35:e.key==='ArrowRight'?-35:0;panY+=e.key==='ArrowUp'?35:e.key==='ArrowDown'?-35:0;paintImage();} }); - new ResizeObserver(paintImage).observe(viewport); + new scope.ResizeObserver(paintImage).observe(viewport); $('route-expand').onclick=()=>{ const dialog=$('image-dialog'),img=$('image-dialog-image'),caption=$('image-dialog-caption'); img.src=routeImage.src;img.alt=routeImage.alt; @@ -224,23 +227,24 @@ worldDialog.querySelector('header button').onclick=()=>worldDialog.close();worldDialog.showModal(); const frame=worldDialog.querySelector('.world-dialog-scroll');frame.scrollLeft=(selected.lon+180)/360*map.clientWidth-frame.clientWidth/2; } - worldDialog.addEventListener('click',e=>{if(e.target===worldDialog)worldDialog.close();}); - worldDialog.addEventListener('close',()=>{worldDialog.replaceChildren();if(worldOpener&&worldOpener.isConnected)worldOpener.focus({preventScroll:true});}); + scope.listen(worldDialog, 'click',e=>{if(e.target===worldDialog)worldDialog.close();}); + scope.listen(worldDialog, 'close',()=>{worldDialog.replaceChildren();if(worldOpener&&worldOpener.isConnected)worldOpener.focus({preventScroll:true});}); const worldButton=document.createElement('button');worldButton.type='button';worldButton.id='world-expand';worldButton.className='rover-button';worldButton.textContent='放大地图 ↗';worldButton.onclick=openWorld; document.querySelector('.rover-world .map-topline small').replaceWith(worldButton); - $('rover-world-map').addEventListener('click',e=>{if(!e.target.closest('.rover-pin'))openWorld();}); + scope.listen($('rover-world-map'), 'click',e=>{if(!e.target.closest('.rover-pin'))openWorld();}); const worldFrame=document.querySelector('.world-map-scroll');worldFrame.setAttribute('aria-label','火星全球着陆区地图,按回车放大,点击标记查看巡视器介绍'); - worldFrame.addEventListener('keydown',e=>{if(e.target===worldFrame&&(e.key==='Enter'||e.key===' ')){e.preventDefault();openWorld();}}); + scope.listen(worldFrame, 'keydown',e=>{if(e.target===worldFrame&&(e.key==='Enter'||e.key===' ')){e.preventDefault();openWorld();}}); const summary=document.querySelector('.rover-summary');summary.id='rover-introduction';summary.tabIndex=-1; let navigation=0; function navigateRover(id,updateHash=true){ if(!data.some(r=>r.id===id))return; const token=++navigation;selectRover(id);showTab(2,false); - if(updateHash)history.pushState(null,'','#rovers/'+id); - setTimeout(()=>{if(token!==navigation)return;summary.focus({preventScroll:true});summary.scrollIntoView({behavior:reducedMotion.matches?'instant':'smooth',block:'center'});},reducedMotion.matches?0:340); + if(updateHash)scope.router.push({hash:'#rovers/'+id}); + scope.setTimeout(()=>{if(token!==navigation)return;summary.focus({preventScroll:true});summary.scrollIntoView({behavior:reducedMotion.matches?'instant':'smooth',block:'center'});},reducedMotion.matches?0:340); } function readRoverHash(){const id=location.hash.slice('#rovers/'.length);if(location.hash.startsWith('#rovers/')&&data.some(r=>r.id===id))navigateRover(id,false);else if(location.hash.startsWith('#rovers'))showTab(2,false);} - window.addEventListener('hashchange',readRoverHash); + scope.listen(window, 'hashchange',readRoverHash); readRoverHash(); if(location.hash.startsWith('#pioneer'))showTab(0,false); })(); +} diff --git a/mars/simulation.js b/src/scripts/mars/simulation.js similarity index 94% rename from mars/simulation.js rename to src/scripts/mars/simulation.js index 682feee..4a5e493 100644 --- a/mars/simulation.js +++ b/src/scripts/mars/simulation.js @@ -1,3 +1,5 @@ + +export function initMarsSimulation(scope) { (function () { 'use strict'; const TAU = Math.PI * 2, EARTH_YEAR = 365.256, MARS_YEAR = 686.980, R = 1.52371; @@ -32,7 +34,7 @@ ['东方照', CYCLE - quadrature, '太阳以东 90° · 前半夜'], ['再次合', CYCLE, '完成一个会合周期'] ]; - window.MarsModel = { geo, CYCLE, OPPOSITION, stops }; + scope.expose('MarsModel', { geo, CYCLE, OPPOSITION, stops }); let day = OPPOSITION, playing = false, speed = 1, last = 0; function buildStatic() { $('day').max = CYCLE; @@ -72,7 +74,7 @@ } function setDay(value,pause=false) { if(pause)setPlaying(false);day=clamp(value,0,CYCLE);render(); } function setPlaying(value) { playing=value;last=0;$('play').textContent=value?'Ⅱ':'▶';$('play').setAttribute('aria-label',value?'暂停日心轨道与视运动':'播放日心轨道与视运动');$('play').setAttribute('aria-pressed',String(value));const sun=$('orbit-sun');sun.classList.toggle('is-playing',value);sun.setAttribute('aria-label',value?'暂停日心轨道与视运动':'播放日心轨道与视运动');sun.setAttribute('aria-pressed',String(value));window.dispatchEvent(new CustomEvent('mars-orbit-playchange',{detail:{playing}})); } - function frame(t) { if(playing && !document.hidden) { if(last) { day=(day+Math.min(t-last,100)*.018*speed)%CYCLE;render(); } last=t; } else last=0;requestAnimationFrame(frame); } + function frame(t) { if(playing && !document.hidden) { if(last) { day=(day+Math.min(t-last,100)*.018*speed)%CYCLE;render(); } last=t; } else last=0;scope.requestAnimationFrame(frame); } const togglePlaying=()=>setPlaying(!playing); $('play').onclick=togglePlaying; $('orbit-sun').onclick=togglePlaying; @@ -100,7 +102,7 @@ caption.replaceChildren(content); } function openImage(card) { dialogImage.src=card.dataset.imageFull;dialogImage.alt=card.querySelector('img').alt;const copy=card.querySelector('.terrain-copy');if(copy){const fullCopy=copy.cloneNode(true);fullCopy.querySelector('.terrain-toggle')?.remove();fullCopy.querySelectorAll('[id]').forEach(el=>el.removeAttribute('id'));caption.replaceChildren(fullCopy)}else formatLandformCaption(card.dataset.imageCaption);dialog.showModal();dialog.classList.add('is-controls-visible'); } - document.querySelectorAll('[data-image-full]').forEach(card=>{card.onclick=e=>{if(!e.target.closest('a'))openImage(card)};if(card.classList.contains('terrain-card')){card.tabIndex=0;card.setAttribute('role','button');card.addEventListener('keydown',e=>{if(e.target===card && (e.key==='Enter'||e.key===' ')){e.preventDefault();openImage(card)}})}}); + document.querySelectorAll('[data-image-full]').forEach(card=>{card.onclick=e=>{if(!e.target.closest('a'))openImage(card)};if(card.classList.contains('terrain-card')){card.tabIndex=0;card.setAttribute('role','button');scope.listen(card, 'keydown',e=>{if(e.target===card && (e.key==='Enter'||e.key===' ')){e.preventDefault();openImage(card)}})}}); document.querySelectorAll('.terrain-grid .terrain-card').forEach((card,index)=>{ const copy=card.querySelector('.terrain-copy'),paragraphs=copy.querySelectorAll(':scope > p'); if(!paragraphs.length)return; @@ -111,7 +113,7 @@ toggle.type='button';toggle.className='terrain-toggle';toggle.textContent='展开介绍'; toggle.setAttribute('aria-expanded','false');toggle.setAttribute('aria-controls',description.id); toggle.setAttribute('aria-label',`展开${copy.querySelector('h3').textContent}介绍`); - toggle.addEventListener('click',event=>{ + scope.listen(toggle, 'click',event=>{ event.stopPropagation();const open=card.classList.toggle('is-expanded'); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'收起介绍':'展开介绍'; toggle.setAttribute('aria-label',`${open?'收起':'展开'}${copy.querySelector('h3').textContent}介绍`); @@ -119,6 +121,7 @@ description.after(toggle);card.classList.add('has-disclosure'); }); $('image-dialog-close').onclick=()=>dialog.close();dialog.onclick=e=>{if(e.target===dialog)dialog.close()}; - window.MarsOrbitControl={CYCLE,getDay:()=>day,setDay,toggle:()=>setPlaying(!playing),setPlaying,isPlaying:()=>playing}; - buildStatic();render();requestAnimationFrame(frame); + scope.expose('MarsOrbitControl', {CYCLE,getDay:()=>day,setDay,toggle:()=>setPlaying(!playing),setPlaying,isPlaying:()=>playing}); + buildStatic();render();scope.requestAnimationFrame(frame); })(); +} diff --git a/mars/sky-model.js b/src/scripts/mars/sky-model.js similarity index 96% rename from mars/sky-model.js rename to src/scripts/mars/sky-model.js index 2c1813b..1fd0f1b 100644 --- a/mars/sky-model.js +++ b/src/scripts/mars/sky-model.js @@ -1,3 +1,5 @@ + +export function initMarsSkyModel(scope) { /* JPL approximate planetary elements, 1800–2050, J2000 ecliptic frame. https://ssd.jpl.nasa.gov/planets/approx_pos.html Positions are geometric: Earth–Moon barycenter approximates Earth; @@ -55,6 +57,6 @@ return wrap(position(ms).longitude*RAD-Math.atan2(e.y,e.x)); },Date.UTC(2025,0,10),Date.UTC(2025,0,20)); const api={DAY,start,end,position,rate,heliocentric,stationary,opposition}; - if(typeof module==='object' && module.exports) module.exports=api; - else root.MarsSkyModel=api; + root.MarsSkyModel=api; })(typeof globalThis==='object'?globalThis:this); +} diff --git a/mars/sky.js b/src/scripts/mars/sky.js similarity index 96% rename from mars/sky.js rename to src/scripts/mars/sky.js index 08c183e..435a392 100644 --- a/mars/sky.js +++ b/src/scripts/mars/sky.js @@ -1,3 +1,5 @@ + +export function initMarsSky(scope) { (function () { 'use strict'; const $=id=>document.getElementById(id), model=window.MarsSkyModel; @@ -71,7 +73,8 @@ button.textContent=names[i]+' · '+dateText(dates[i]).slice(5).replace('-','.'); button.onclick=()=>orbit.setDay(dayForTime(dates[i]),true); }); - window.addEventListener('mars-orbit-timechange',event=>render(event.detail.day)); - window.MarsSkyView={pointAt,scale,timeForDay,dayForTime}; + scope.listen(window, 'mars-orbit-timechange',event=>render(event.detail.day)); + scope.expose('MarsSkyView', {pointAt,scale,timeForDay,dayForTime}); render(); })(); +} diff --git a/delocalized configuration project/terminal.js b/src/scripts/terminal/terminal.js similarity index 91% rename from delocalized configuration project/terminal.js rename to src/scripts/terminal/terminal.js index cc5fb55..01934fe 100644 --- a/delocalized configuration project/terminal.js +++ b/src/scripts/terminal/terminal.js @@ -1,3 +1,7 @@ +import { loadEngine } from '../../utils/engine.js'; +import { assetUrl } from '../../utils/assets.js'; + +export function initTerminal(scope) { (() => { @@ -6,30 +10,11 @@ let THREE; let OrbitControls; let threeLoadPromise; - function loadScript(src) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.async = true; - script.onload = resolve; - script.onerror = () => reject(new Error(`Unable to load ${src}`)); - document.head.appendChild(script); - }); - } - function loadThree() { if (!threeLoadPromise) { - threeLoadPromise = loadScript('../vendor/three/build/three.min.js') - .then(() => loadScript('../vendor/three/examples/jsm/controls/OrbitControls.global.js')) - .then(() => location.protocol === 'file:' - ? loadScript('../assets/textures/earth_atmos_2048.data.js') - : undefined) - .then(() => { - THREE = globalThis.THREE; - OrbitControls = globalThis.OrbitControls; - if (!THREE || !OrbitControls) { - throw new Error('Local Three.js runtime did not initialize'); - } + threeLoadPromise = loadEngine().then(engine => { + THREE = engine; + OrbitControls = window.OrbitControls; }); } return threeLoadPromise; @@ -71,11 +56,11 @@ } } - orbitTabBtns.forEach(btn => btn.addEventListener('click', () => { + orbitTabBtns.forEach(btn => scope.listen(btn, 'click', () => { const target = btn.dataset.target; switchOrbitPanel(target, target !== 'global'); })); - vehicleCards.forEach(card => card.addEventListener('click', () => { + vehicleCards.forEach(card => scope.listen(card, 'click', () => { switchOrbitPanel(card.dataset.sc, true); })); @@ -83,7 +68,7 @@ const speedBtns = Array.from(document.querySelectorAll('.speed-btn')); let orbitSpeed = 1; let onOrbitSpeedChange = null; // 由 3D 场景注册:速度档位变化时刷新角速度/周期显示 - speedBtns.forEach(btn => btn.addEventListener('click', () => { + speedBtns.forEach(btn => scope.listen(btn, 'click', () => { orbitSpeed = parseFloat(btn.dataset.speed); speedBtns.forEach(b => b.classList.toggle('active', b === btn)); if (onOrbitSpeedChange) onOrbitSpeedChange(); @@ -114,11 +99,11 @@ window.SiteMusic.startTerminal(); let i = 0; - const iv = setInterval(() => { + const iv = scope.setInterval(() => { statusEl.textContent = MSGS[i++]; if (i >= MSGS.length) { - clearInterval(iv); - setTimeout(() => { + scope.clearInterval(iv); + scope.setTimeout(() => { startTransition(); }, 400); } @@ -146,17 +131,17 @@ bEmblem.classList.add('periph-gone'); // 外围轨道消失后,保留的核心航天器图案单独上升。 - setTimeout(() => { + scope.setTimeout(() => { floatingEmblem.classList.add('emblem-rise'); }, 1500); // 上升完成后淡出徽标,再让主 UI 进入。 - setTimeout(() => { + scope.setTimeout(() => { floatingEmblem.classList.add('emblem-gone'); mainUI.classList.add('active'); mainUI.style.transition = 'opacity 0.8s ease, transform 0.8s ease'; - requestAnimationFrame(() => { - requestAnimationFrame(() => { + scope.requestAnimationFrame(() => { + scope.requestAnimationFrame(() => { mainUI.classList.add('visible'); }); }); @@ -164,18 +149,19 @@ }, 2600); // 清理 - setTimeout(() => { + scope.setTimeout(() => { bootScreen.style.display = 'none'; floatingEmblem.style.display = 'none'; bgTransition.style.display = 'none'; }, 3500); } - initBtn.addEventListener('click', boot); + scope.listen(initBtn, 'click', boot); // ---------- 3D 场景(保持不变) ---------- async function startApp() { await loadThree(); + if (!scope.active) return; wrapper.classList.remove('is-model-ready'); const scene = new THREE.Scene(); scene.background = new THREE.Color('#ece6dc'); @@ -196,6 +182,7 @@ container.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); + scope.trackScene(scene, renderer, controls); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.autoRotate = true; @@ -219,9 +206,7 @@ const earthGroup = new THREE.Group(); scene.add(earthGroup); const radius = 1.25; - const textureUrl = location.protocol === 'file:' - ? globalThis.EARTH_TEXTURE_DATA_URL - : '../assets/textures/earth_atmos_2048.jpg'; + const textureUrl = assetUrl('images/textures/earth_atmos_2048.jpg'); let earthTextureReady = false; const tex = new THREE.TextureLoader().load( textureUrl, @@ -514,13 +499,13 @@ lockDir.normalize(); } - renderer.domElement.addEventListener('pointerdown', onCanvasPointerDown); - renderer.domElement.addEventListener('click', onCanvasClick); - renderer.domElement.addEventListener('pointermove', onCanvasPointerMove); - renderer.domElement.addEventListener('pointerup', endLockDrag); - renderer.domElement.addEventListener('pointercancel', endLockDrag); + scope.listen(renderer.domElement, 'pointerdown', onCanvasPointerDown); + scope.listen(renderer.domElement, 'click', onCanvasClick); + scope.listen(renderer.domElement, 'pointermove', onCanvasPointerMove); + scope.listen(renderer.domElement, 'pointerup', endLockDrag); + scope.listen(renderer.domElement, 'pointercancel', endLockDrag); // 锁定时滚轮缩放(小球仍保持静止) - renderer.domElement.addEventListener('wheel', (e) => { + scope.listen(renderer.domElement, 'wheel', (e) => { if (!lockObj) return; e.preventDefault(); lockDistance = Math.max(1.2, Math.min(8, lockDistance + e.deltaY * 0.004)); @@ -578,7 +563,7 @@ const followPos = new THREE.Vector3(); let lastFrameTime = performance.now(); function animate(now) { - requestAnimationFrame(animate); + scope.requestAnimationFrame(animate); // 按真实时间差驱动运动,帧率(60/120/144Hz)不再影响速度 const delta = Math.min(Math.max((now - lastFrameTime) / 1000, 0), 0.1); lastFrameTime = now; @@ -617,8 +602,8 @@ camera.updateProjectionMatrix(); renderer.setSize(w, h); } - window.addEventListener('resize', onResize); - new ResizeObserver(onResize).observe(wrapper); + scope.listen(window, 'resize', onResize); + new scope.ResizeObserver(onResize).observe(wrapper); function updateClock() { const now = new Date(); @@ -628,9 +613,9 @@ dom.clock.textContent = `${h}:${m}:${s}`; } updateClock(); - setInterval(updateClock, 1000); + scope.setInterval(updateClock, 1000); - setInterval(() => { + scope.setInterval(() => { dom.altitude.textContent = (400 + (Math.sin(Date.now()/5000)*0.00025 + 2.5)).toFixed(1); dom.velocity.textContent = (7.66 + Math.sin(Date.now()/3000)*0.0002).toFixed(3); }, 200); @@ -640,11 +625,12 @@ const back = document.getElementById('terminal-close'); if (location.protocol === 'file:') back.href = '../index.html#dc'; - document.addEventListener('keydown', event => { + scope.listen(document, 'keydown', event => { if (event.key === 'Escape') back.click(); }); - window.addEventListener('pageshow', event => { + scope.listen(window, 'pageshow', event => { // A fresh entry, including browser Back/Forward, starts at the boot screen. if (event.persisted) location.reload(); }); })(); +} diff --git a/venus/model.js b/src/scripts/venus/model.js similarity index 83% rename from venus/model.js rename to src/scripts/venus/model.js index cb63adf..b8c8624 100644 --- a/venus/model.js +++ b/src/scripts/venus/model.js @@ -1,28 +1,12 @@ -let enginePromise; -function loadScript(path) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = new URL(path, import.meta.url).href; - script.onload = resolve; - script.onerror = () => reject(new Error('Unable to load Venus renderer')); - document.head.append(script); - }); -} -async function loadEngine() { - if (!enginePromise) { - enginePromise = (async () => { - if (!window.THREE) await loadScript('../vendor/three/build/three.min.js'); - if (!window.OrbitControls) await loadScript('../vendor/three/examples/jsm/controls/OrbitControls.global.js'); - return window.THREE; - })(); - } - return enginePromise; -} +import { loadEngine } from '../../utils/engine.js'; +import { assetUrl } from '../../utils/assets.js'; // Share the project's original globe, surface map and glow with the home preview. -export async function mountVenus(stage, mount, { preview = false, surface = './venus-surface.jpg', tilt = 177.4 } = {}) { +export async function mountVenus(stage, mount, { preview = false, surface = 'images/venus/venus-surface.jpg', tilt = 177.4 } = {}) { try { const THREE = await loadEngine(); + if (!stage?.isConnected || !mount?.isConnected) return; + let disposed = false; const OrbitControls = window.OrbitControls; const scene = new THREE.Scene(); @@ -51,10 +35,11 @@ export async function mountVenus(stage, mount, { preview = false, surface = './v const sphere = new THREE.Mesh(new THREE.SphereGeometry(1.34, 64, 64), material); world.add(sphere); - const textureSource = new URL(surface, import.meta.url).href; + const textureSource = assetUrl(surface); const texture = new THREE.TextureLoader().load( textureSource, map => { + if (disposed || !stage.isConnected) { map.dispose(); return; } map.colorSpace = THREE.SRGBColorSpace; map.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 4); material.map = map; @@ -136,6 +121,7 @@ export async function mountVenus(stage, mount, { preview = false, surface = './v controls.zoomSpeed = .9; const resize = () => { + if (disposed) return; const width = Math.max(mount.clientWidth, 1); const height = Math.max(mount.clientHeight, 1); renderer.setSize(width, height, false); @@ -158,7 +144,7 @@ export async function mountVenus(stage, mount, { preview = false, surface = './v let lastRenderTime = 0; const frameInterval = 1000 / (preview ? 30 : 60); const animate = time => { - if (!isRunning) return; + if (disposed || !isRunning) return; animationFrame = requestAnimationFrame(animate); if (time - lastRenderTime < frameInterval) return; const delta = Math.min((time - lastRenderTime) / 1000, .05); @@ -168,6 +154,7 @@ export async function mountVenus(stage, mount, { preview = false, surface = './v }; const syncAnimation = () => { + if (disposed) return; const shouldRun = isInView && !document.hidden && !document.documentElement.classList.contains('dc-navigation-active'); if (shouldRun && !isRunning) { isRunning = true; @@ -188,6 +175,27 @@ export async function mountVenus(stage, mount, { preview = false, surface = './v window.addEventListener('site-navigation', syncAnimation); window.addEventListener('pageshow', syncAnimation); syncAnimation(); + return () => { + if (disposed) return; + disposed = true; + isRunning = false; + cancelAnimationFrame(animationFrame); + resizeObserver.disconnect(); + visibilityObserver.disconnect(); + document.removeEventListener('visibilitychange', syncAnimation); + window.removeEventListener('site-navigation', syncAnimation); + window.removeEventListener('pageshow', syncAnimation); + controls.dispose(); + scene.traverse(object => { + object.geometry?.dispose(); + if (Array.isArray(object.material)) object.material.forEach(material => material.dispose()); + else object.material?.dispose(); + }); + texture.dispose(); + renderer.dispose(); + renderer.forceContextLoss(); + renderer.domElement.remove(); + }; } catch (error) { stage.classList.add('is-error'); console.error('Venus engine unavailable:', error); diff --git a/venus/venus.js b/src/scripts/venus/venus.js similarity index 90% rename from venus/venus.js rename to src/scripts/venus/venus.js index 44e13c5..abedc33 100644 --- a/venus/venus.js +++ b/src/scripts/venus/venus.js @@ -1,3 +1,5 @@ + +export function initVenusSimulation(scope) { const TAU=Math.PI*2,CYCLE=583.92,VR=.723,INC=3.394*Math.PI/180,NODE=1.34; const $=id=>document.getElementById(id),clamp=(v,a,b)=>Math.min(b,Math.max(a,v)); function geo(day){const phaseDay=Math.abs(day-CYCLE)<1e-9?0:((day%CYCLE)+CYCLE)%CYCLE,cycleAngle=phaseDay/CYCLE*TAU,ea=cycleAngle,va=Math.PI+cycleAngle*2,u=va-NODE,cn=Math.cos(NODE),sn=Math.sin(NODE),cu=Math.cos(u),su=Math.sin(u),ci=Math.cos(INC),si=Math.sin(INC),e={x:Math.cos(ea),y:Math.sin(ea),z:0},v={x:VR*(cn*cu-sn*su*ci),y:VR*(sn*cu+cn*su*ci),z:VR*su*si},g={x:v.x-e.x,y:v.y-e.y,z:v.z},s={x:-e.x,y:-e.y,z:0},ve={x:e.x-v.x,y:e.y-v.y,z:-v.z},vs={x:-v.x,y:-v.y,z:-v.z},d=Math.hypot(g.x,g.y,g.z),dot=(a,b)=>a.x*b.x+a.y*b.y+(a.z||0)*(b.z||0),cross=(a,b)=>a.x*b.y-a.y*b.x,elong=Math.atan2(cross(s,g),dot(s,g))*180/Math.PI,alpha=Math.acos(clamp(dot(vs,ve)/(Math.hypot(vs.x,vs.y,vs.z)*d),-1,1))*180/Math.PI,lit=(1+Math.cos(alpha*Math.PI/180))/2,uPhase=alpha/100,pt=alpha<=163.6?-4.47+1.03*uPhase+.57*uPhase*uPhase+.13*uPhase*uPhase*uPhase:.98-1.02*uPhase;return{e,v,d,elong,alpha,lit,mag:5*Math.log10(VR*d)+pt,diam:16.68/d,lat:Math.atan2(g.z,Math.hypot(g.x,g.y))*180/Math.PI}} @@ -18,16 +20,16 @@ const TAU=Math.PI*2,CYCLE=583.92,VR=.723,INC=3.394*Math.PI/180,NODE=1.34; function setPlaying(value){playing=value;last=0;$('play').textContent=value?'Ⅱ':'▶';$('play').setAttribute('aria-label',value?'暂停日心轨道演示':'播放日心轨道演示');$('play').setAttribute('aria-pressed',String(value));const sun=$('orbit-sun');sun.classList.toggle('is-playing',value);sun.setAttribute('aria-label',value?'暂停日心轨道演示':'播放日心轨道演示');sun.setAttribute('aria-pressed',String(value))} function setDay(value,pause=false){if(pause)setPlaying(false);day=clamp(value,0,CYCLE);$('day').value=day;render()} const togglePlaying=()=>setPlaying(!playing); - function frame(t){if(playing&&!document.hidden){if(last){day+=((t-last)/40)*.55*speed;if(day>=CYCLE)day%=CYCLE;$('day').value=day;render()}last=t}else last=0;requestAnimationFrame(frame)} + function frame(t){if(playing&&!document.hidden){if(last){day+=((t-last)/40)*.55*speed;if(day>=CYCLE)day%=CYCLE;$('day').value=day;render()}last=t}else last=0;scope.requestAnimationFrame(frame)} $('play').onclick=togglePlaying;$('orbit-sun').onclick=togglePlaying;$('orbit-sun').onkeydown=event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();togglePlaying()}};$('reset').onclick=()=>setDay(0,true);$('day').oninput=e=>setDay(+e.target.value,true);$('trail-toggle').onchange=e=>$('trail').style.display=e.target.checked?'':'none';document.querySelectorAll('[data-speed]').forEach(b=>b.onclick=()=>{speed=+b.dataset.speed;document.querySelectorAll('[data-speed]').forEach(x=>{x.classList.toggle('active',x===b);x.setAttribute('aria-pressed',String(x===b))})}); const intro=$('venus-intro'),introToggle=$('intro-toggle'); function setIntro(open){intro.classList.toggle('is-open',open);introToggle.setAttribute('aria-expanded',String(open));introToggle.setAttribute('aria-label',open?'收起金星介绍':'展开金星介绍');$('intro-toggle-hint').textContent=open?'点此收起介绍':'点此展开介绍'} introToggle.onclick=()=>setIntro(!intro.classList.contains('is-open'));setIntro(false); const imageDialog=$('image-dialog'),imageDialogImage=$('image-dialog-image'),imageDialogCaption=$('image-dialog-caption');let dialogControlsTimer; - function revealDialogControls(){clearTimeout(dialogControlsTimer);imageDialog.classList.add('is-controls-visible');if(imageDialog.open)dialogControlsTimer=setTimeout(()=>imageDialog.classList.remove('is-controls-visible'),2000)} - function closeImageDialog(){clearTimeout(dialogControlsTimer);imageDialog.classList.remove('is-controls-visible');imageDialog.close()} + function revealDialogControls(){scope.clearTimeout(dialogControlsTimer);imageDialog.classList.add('is-controls-visible');if(imageDialog.open)dialogControlsTimer=scope.setTimeout(()=>imageDialog.classList.remove('is-controls-visible'),2000)} + function closeImageDialog(){scope.clearTimeout(dialogControlsTimer);imageDialog.classList.remove('is-controls-visible');imageDialog.close()} function openImageDialog(card){imageDialogImage.src=card.dataset.imageFull;imageDialogImage.alt=card.querySelector('img').alt;const explanation=card.querySelector('.terrain-copy'),dialogTitle=card.dataset.imageDialogTitle;if(explanation){const copy=explanation.cloneNode(true);copy.querySelector('.terrain-toggle')?.remove();copy.querySelectorAll('[id]').forEach(el=>el.removeAttribute('id'));imageDialogCaption.replaceChildren(copy)}else if(dialogTitle)imageDialogCaption.innerHTML=`${dialogTitle}${card.dataset.imageSubtitle}`;else imageDialogCaption.textContent=card.dataset.imageCaption;imageDialog.showModal();revealDialogControls()} - document.querySelectorAll('[data-image-full]').forEach(card=>{card.onclick=()=>openImageDialog(card);if(card.classList.contains('terrain-card')){card.tabIndex=0;card.addEventListener('keydown',e=>{if(e.target===card&&(e.key==='Enter'||e.key===' ')){e.preventDefault();openImageDialog(card)}})}}); + document.querySelectorAll('[data-image-full]').forEach(card=>{card.onclick=()=>openImageDialog(card);if(card.classList.contains('terrain-card')){card.tabIndex=0;scope.listen(card, 'keydown',e=>{if(e.target===card&&(e.key==='Enter'||e.key===' ')){e.preventDefault();openImageDialog(card)}})}}); document.querySelectorAll('.terrain-grid .terrain-card').forEach((card,index)=>{ const copy=card.querySelector('.terrain-copy'),paragraphs=copy.querySelectorAll(':scope > p'); if(!paragraphs.length)return; @@ -38,7 +40,7 @@ const TAU=Math.PI*2,CYCLE=583.92,VR=.723,INC=3.394*Math.PI/180,NODE=1.34; toggle.type='button';toggle.className='terrain-toggle';toggle.textContent='展开介绍'; toggle.setAttribute('aria-expanded','false');toggle.setAttribute('aria-controls',description.id); toggle.setAttribute('aria-label',`展开${copy.querySelector('h3').textContent}介绍`); - toggle.addEventListener('click',event=>{ + scope.listen(toggle, 'click',event=>{ event.stopPropagation();const open=card.classList.toggle('is-expanded'); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'收起介绍':'展开介绍'; toggle.setAttribute('aria-label',`${open?'收起':'展开'}${copy.querySelector('h3').textContent}介绍`); @@ -47,6 +49,7 @@ const TAU=Math.PI*2,CYCLE=583.92,VR=.723,INC=3.394*Math.PI/180,NODE=1.34; }); $('image-dialog-close').onclick=closeImageDialog; imageDialog.onclick=e=>{if(e.target===imageDialog)closeImageDialog()}; - ['pointermove','wheel','keydown','touchstart'].forEach(type=>imageDialog.addEventListener(type,revealDialogControls,{passive:true})); - imageDialog.addEventListener('close',()=>{clearTimeout(dialogControlsTimer);imageDialog.classList.remove('is-controls-visible')}); - makeStatic();render();requestAnimationFrame(frame); + ['pointermove','wheel','keydown','touchstart'].forEach(type=>scope.listen(imageDialog, type,revealDialogControls,{passive:true})); + scope.listen(imageDialog, 'close',()=>{scope.clearTimeout(dialogControlsTimer);imageDialog.classList.remove('is-controls-visible')}); + makeStatic();render();scope.requestAnimationFrame(frame); +} diff --git a/src/seo.js b/src/seo.js new file mode 100644 index 0000000..91e9557 --- /dev/null +++ b/src/seo.js @@ -0,0 +1,102 @@ +import { assetUrl } from './utils/assets.js'; + +export const site = { url: 'https://empty610.com/', name: 'website · empty610', author: 'empty610' }; +export const pages = { + home: { + path: '/', label: '个人主页', title: 'empty610|个人网站与天文学互动项目 · website', + description: 'empty610 的个人网站,分享天文学兴趣、金星与火星的三维模型、轨道演示和 DC 航天导航互动项目。', + image: 'images/textures/earth_atmos_2048.jpg', imageAlt: '地球表面贴图:empty610 的天文学互动项目', + }, + venus: { + path: '/venus/', label: '金星', title: '金星|三维模型、地表与轨道演示 · empty610', + description: '探索金星的三维雷达地表模型、极端大气与地貌,交互查看日心轨道、相位和晨昏星变化。教学演示使用简化模型。', + image: 'images/venus/venus-surface.jpg', imageAlt: '金星的麦哲伦雷达地表贴图', + }, + mars: { + path: '/mars/', label: '火星', title: '火星|三维模型、巡视器与轨道演示 · empty610', + description: '探索火星和两颗卫星的三维模型,查看巡视器路线、科研成果、地表地貌及逆行演示。教学演示使用简化模型。', + image: 'images/mars/mars-texture.jpg', imageAlt: '火星全球表面贴图', + }, + terminal: { + path: '/delocalized%20configuration%20project/', label: 'DC INS 航天导航终端', title: 'DC INS|交互式航天导航终端 · empty610', + description: '体验 empty610 的 DC INS 航天导航交互终端,查看地球与航天器轨道、任务信息和姿态控制演示。', + image: 'images/textures/earth_atmos_2048.jpg', imageAlt: 'DC INS 地球与航天器轨道演示使用的地球贴图', + }, +}; + +const escape = text => String(text).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); +export const canonicalUrl = page => new URL(page.path, site.url).href; +const imageUrl = page => new URL(assetUrl(page.image), site.url).href; + +function tags(page) { + return [ + ['name', 'description', page.description], + ['name', 'robots', 'index,follow,max-image-preview:large'], + ['property', 'og:type', 'website'], + ['property', 'og:site_name', site.name], + ['property', 'og:locale', 'zh_CN'], + ['property', 'og:title', page.title], + ['property', 'og:description', page.description], + ['property', 'og:url', canonicalUrl(page)], + ['property', 'og:image', imageUrl(page)], + ['property', 'og:image:alt', page.imageAlt], + ['name', 'twitter:card', 'summary_large_image'], + ['name', 'twitter:title', page.title], + ['name', 'twitter:description', page.description], + ['name', 'twitter:image', imageUrl(page)], + ['name', 'twitter:image:alt', page.imageAlt], + ]; +} + +function schema(page) { + const url = canonicalUrl(page); + const graph = [ + { '@type': 'Person', '@id': site.url + '#author', name: site.author, url: site.url }, + { '@type': 'WebSite', '@id': site.url + '#website', url: site.url, name: site.name, alternateName: 'empty610', inLanguage: 'zh-CN', author: { '@id': site.url + '#author' } }, + { + '@type': 'WebPage', '@id': url + '#page', url, name: page.title, description: page.description, inLanguage: 'zh-CN', + isPartOf: { '@id': site.url + '#website' }, author: { '@id': site.url + '#author' }, + primaryImageOfPage: { '@type': 'ImageObject', contentUrl: imageUrl(page) }, + ...(page.path === '/' ? {} : { breadcrumb: { '@id': url + '#breadcrumb' } }), + }, + ]; + if (page.path !== '/') graph.push({ + '@type': 'BreadcrumbList', '@id': url + '#breadcrumb', itemListElement: [ + { '@type': 'ListItem', position: 1, name: site.name, item: site.url }, + { '@type': 'ListItem', position: 2, name: page.label, item: url }, + ], + }); + return JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replaceAll('<', '\\u003c'); +} + +export function renderSeoHead(page) { + return [ + ``, + ...tags(page).map(([attribute, key, content]) => ``), + ``, + ].join('\n'); +} + +export function updateSeo(page) { + document.title = page.title; + function element(tag, key) { + let node = document.head.querySelector(`[data-seo="${key}"]`); + if (!node) { + node = document.createElement(tag); + node.dataset.seo = key; + document.head.append(node); + } + return node; + } + const canonical = element('link', 'canonical'); + canonical.rel = 'canonical'; + canonical.href = canonicalUrl(page); + for (const [attribute, key, content] of tags(page)) { + const node = element('meta', key); + node.setAttribute(attribute, key); + node.content = content; + } + const structured = element('script', 'schema'); + structured.type = 'application/ld+json'; + structured.textContent = schema(page); +} diff --git a/src/utils/assets.js b/src/utils/assets.js new file mode 100644 index 0000000..65b055d --- /dev/null +++ b/src/utils/assets.js @@ -0,0 +1,16 @@ +// Vite resolves imported media in development and hashes it for production. +// The map also covers filenames selected dynamically by the rover/model UI. +const assets = import.meta.glob('../assets/**/*.{jpg,png,webp,mp3,woff2}', { + eager: true, + query: '?url', + import: 'default', +}); + +export function assetUrl(path) { + const asset = assets[`../assets/${path}`]; + if (asset) return asset; + if (!path.startsWith('vendor/') && !path.startsWith('data/')) { + throw new Error(`Unknown asset: ${path}`); + } + return import.meta.env.BASE_URL + path; +} diff --git a/src/utils/engine.js b/src/utils/engine.js new file mode 100644 index 0000000..8e62baf --- /dev/null +++ b/src/utils/engine.js @@ -0,0 +1,36 @@ +import { assetUrl } from './assets.js'; + +const scripts = new Map(); +let enginePromise; + +export function loadScript(path) { + if (!scripts.has(path)) { + const promise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = assetUrl(path); + script.onload = resolve; + script.onerror = () => reject(new Error(`Unable to load ${path}`)); + document.head.append(script); + }); + scripts.set(path, promise.catch(error => { + scripts.delete(path); + throw error; + })); + } + return scripts.get(path); +} + +export function loadEngine({ mars = false } = {}) { + if (!enginePromise) { + const folder = mars ? 'vendor/mars/' : 'vendor/three/'; + enginePromise = (async () => { + await loadScript(folder + (mars ? 'three.js' : 'build/three.min.js')); + await loadScript(folder + (mars ? 'OrbitControls.js' : 'examples/jsm/controls/OrbitControls.global.js')); + return window.THREE; + })().catch(error => { + enginePromise = undefined; + throw error; + }); + } + return enginePromise; +} diff --git a/idle-controls.js b/src/utils/idle-controls.js similarity index 63% rename from idle-controls.js rename to src/utils/idle-controls.js index 563e1fd..1cf73aa 100644 --- a/idle-controls.js +++ b/src/utils/idle-controls.js @@ -1,3 +1,5 @@ + +export function initIdleControls(scope) { (() => { const controls = Array.from(document.querySelectorAll('[data-idle-hide]')); if (!controls.length) return; @@ -5,11 +7,11 @@ let timer; const visibleControls = () => controls.filter((control) => control.getClientRects().length > 0); const reveal = () => { - window.clearTimeout(timer); + scope.clearTimeout(timer); const visible = visibleControls(); visible.forEach((control) => control.classList.remove('is-idle-hidden')); if (!document.hidden && visible.length) { - timer = window.setTimeout(() => { + timer = scope.setTimeout(() => { visibleControls().forEach((control) => control.classList.add('is-idle-hidden')); }, 2000); } @@ -17,14 +19,15 @@ // Only direct input renews an exit control. Rendering and scripted scrolling do not. ['pointermove', 'pointerdown', 'touchstart', 'keydown'].forEach((type) => { - document.addEventListener(type, reveal, { passive: true }); + scope.listen(document, type, reveal, { passive: true }); }); controls.forEach((control) => { - control.addEventListener('focus', reveal); - control.addEventListener('pointerenter', reveal); + scope.listen(control, 'focus', reveal); + scope.listen(control, 'pointerenter', reveal); }); - window.addEventListener('idle-controls-reset', reveal); - window.addEventListener('pageshow', reveal); - window.addEventListener('pagehide', () => window.clearTimeout(timer)); + scope.listen(window, 'idle-controls-reset', reveal); + scope.listen(window, 'pageshow', reveal); + scope.listen(window, 'pagehide', () => scope.clearTimeout(timer)); reveal(); })(); +} diff --git a/src/views/Home.vue b/src/views/Home.vue new file mode 100644 index 0000000..f6abe85 --- /dev/null +++ b/src/views/Home.vue @@ -0,0 +1,199 @@ + + + + + + + diff --git a/mars/index.html b/src/views/Mars.vue similarity index 72% rename from mars/index.html rename to src/views/Mars.vue index 0d399cd..fa2b3c1 100644 --- a/mars/index.html +++ b/src/views/Mars.vue @@ -1,33 +1,50 @@ - - - - - - - Mars · empty610.com - - - - - - - - - - - - - - - - - - - - - - - BACK + + + + + + + + + + + diff --git a/delocalized configuration project/index.html b/src/views/Terminal.vue similarity index 86% rename from delocalized configuration project/index.html rename to src/views/Terminal.vue index 9a8715b..0f5946f 100644 --- a/delocalized configuration project/index.html +++ b/src/views/Terminal.vue @@ -1,21 +1,25 @@ - - - - - - empty610.com/delocalized configuration project - - - - - -
- - + + + + + diff --git a/venus/index.html b/src/views/Venus.vue similarity index 77% rename from venus/index.html rename to src/views/Venus.vue index ee2c12d..a3e9aee 100644 --- a/venus/index.html +++ b/src/views/Venus.vue @@ -1,17 +1,24 @@ - - - - - - - Venus · empty610.com - - - - - - - BACK + + + + + + diff --git a/venus-preview.js b/venus-preview.js deleted file mode 100644 index 148c500..0000000 --- a/venus-preview.js +++ /dev/null @@ -1,28 +0,0 @@ -const section = document.getElementById('venus'); -const stage = document.getElementById('venus-preview-stage'); -const mount = document.getElementById('venus-preview-canvas'); -let started = false; - -async function startPreview() { - if (started) return; - started = true; - try { - const { mountVenus } = await import('./venus/model.js?v=20260923-mars'); - await mountVenus(stage, mount, { preview: true }); - } catch { - stage.classList.add('is-error'); - } -} - -if (section && stage && mount) { - if ('IntersectionObserver' in window) { - const observer = new IntersectionObserver(([entry]) => { - if (!entry.isIntersecting) return; - observer.disconnect(); - startPreview(); - }, { rootMargin: '240px 0px' }); - observer.observe(section); - } else { - startPreview(); - } -} diff --git a/venus/README.md b/venus/README.md deleted file mode 100644 index b0ab91b..0000000 --- a/venus/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Venus - -Integrated from the user's `E:/solar system/venus/venus_v3.html` project. - -- Public entry: `/venus/`. BACK returns to `/#venus`. -- The home page includes a 90vh preview (100vh on phones) between DC and Future, with replayable scroll-entry fades; its renderer loads only near the viewport and pauses when hidden or navigating away. -- `model.js` shares the original sphere, glow and texture between the preview and the interactive project. It uses the site's existing vendored Three.js r160 and OrbitControls, with no CDN or iframe. -- `venus-surface.jpg` is the lossless extraction of the JPEG embedded in the original `venus-texture.js`; the other project images are copied unchanged. -- Original inline CSS and JavaScript are extracted into `venus.css` and `venus.js` to work with the site's existing Content-Security-Policy. `page.js` starts the globe; `site.css` adds site navigation, entry fade and the music button. -- The site's shared `music.js` carries the user's playback preference and position across pages. Browser autoplay restrictions may require one interaction to resume playback. - -Serve the repository over HTTP for local previews (ES modules need HTTP). No build step is required. diff --git a/venus/page.js b/venus/page.js deleted file mode 100644 index 338f88c..0000000 --- a/venus/page.js +++ /dev/null @@ -1,4 +0,0 @@ -import { mountVenus } from './model.js'; -mountVenus(document.getElementById('venus-stage'), document.getElementById('venus-3d')); -const backButton = document.getElementById('venus-back'); -if (location.protocol === 'file:') backButton.href = '../index.html#venus'; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..deb179e --- /dev/null +++ b/vite.config.js @@ -0,0 +1,56 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + // Keep the production CSP. Vite's development client needs injected styles + // and a WebSocket; this change applies only to local development responses. + plugins: [vue({ template: { compilerOptions: { comments: false } } }), { + name: 'development-csp', + apply: 'serve', + transformIndexHtml(html) { + return html.replace(/\s*]*http-equiv="Content-Security-Policy"[^>]*>/gi, ''); + }, + }, { + name: 'page-lifecycle-reload', + // Astronomy modules own long-lived animation state. Recreate the page on + // component/script edits so old observers and render loops cannot survive. + handleHotUpdate({ file, server }) { + if (file.includes('/src/') && /\.(vue|js)$/.test(file)) { + server.ws.send({ type: 'full-reload' }); + return []; + } + }, + }], + server: { host: '127.0.0.1' }, + preview: { host: '127.0.0.1' }, + css: { + postcss: { + plugins: [{ + postcssPlugin: 'route-styles', + Rule(rule) { + const file = rule.source?.input.file?.replaceAll('\\', '/') ?? ''; + const page = file.match(/\/assets\/styles\/(home|mars|venus|terminal)\//)?.[1]; + const shared = file.endsWith('/src/assets/main.css'); + if (!page && !shared) return; + for (let parent = rule.parent; parent; parent = parent.parent) { + if (parent.type === 'atrule' && parent.name.endsWith('keyframes')) return; + } + // Route CSS stays loaded after navigation. Gate its selectors so + // similarly named astronomy panels and page backgrounds cannot clash. + const prefix = shared + ? 'html:is([data-page="home"], [data-page="terminal"])' + : `html[data-page="${page}"]`; + rule.selectors = rule.selectors.map(selector => { + if (selector.startsWith(':root')) return prefix + selector.slice(5); + if (selector.startsWith('html')) return prefix + selector.slice(4); + return prefix + ' ' + selector; + }); + }, + }], + }, + }, + build: { + manifest: true, + assetsInlineLimit: 0, + }, +});