Passes on Mars
–About Mars years.
diff --git a/README.md b/README.md
index 7522246..0bfec63 100644
--- a/README.md
+++ b/README.md
@@ -65,9 +65,32 @@ Where scripts aren't allowed (Notion, many blogs), use the iframe version; URL p
```
+## Light years
+
+[`lib/lightyear.js`](lib/lightyear.js) (one ES module, no dependencies) and the [Light Years page](https://interimm.org/mars-clock/en/lightyear/) ([中文](https://interimm.org/mars-clock/lightyear/)): letters to nearby stars that travel at light speed, and a voyage clock that compares years on Mars with years aboard. Units are light years and Julian years, so c = 1.
+
+```js
+import * as ly from 'https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/lightyear.js';
+
+ly.voyage({ distance: 10.47, accel: 1 }); // 1 g to Epsilon Eridani, flip at the midpoint
+// { earthYears: 12.26, shipYears: 4.93, peakBeta: 0.988, peakGamma: 6.40, phases: [...], ... }
+ly.message(new Date(), ly.star('epsilon-eridani').distance, { replyAfter: 1 });
+// { sent, arrives, earliestReply, ... }
+ly.storyFromReal(new Date()); // the InterImm story date (real + 70,491 days)
+```
+
+| Function | Returns |
+| --- | --- |
+| `voyage({ distance, accel, cruise, decelerate })` | Constant proper acceleration (g) to a cruise speed (fraction of c, or `null` to flip at the midpoint), coast, brake. Years in the Sun/Mars frame and aboard, peak speed, ideal photon-rocket mass ratio, phases. |
+| `voyageAt(v, t)` / `voyageDates(v, depart)` | Position, speed and ship clock `t` years after departure; arrival date and when news of it reaches home. |
+| `PROFILES`, `profileVoyage(id, distance)` | Ready-made ships: `sail`, `ark`, `fast`, `torch`. |
+| `message(sent, distance, { replyAfter })`, `messageProgress`, `emitted` | Light-speed message timing. |
+| `STARS`, `star(id)` | 14 nearby stars with distances in light years. |
+| `storyFromReal`, `realFromStory`, `STORY_OFFSET_DAYS` | The InterImm story calendar. |
+
## For AI assistants
-- **MCP server** (`mcp/server.mjs`, no dependencies, runs locally): `claude mcp add mars-time -- npx -y github:InterImm/mars-clock#gh-pages`, or `{"command": "npx", "args": ["-y", "github:InterImm/mars-clock#gh-pages"]}` in any MCP config. Tools: `get_mars_time`, `interimm_to_earth`, `mission_sol`, `sol_to_earth`, `mars_year_dates`.
+- **MCP server** (`mcp/server.mjs`, no dependencies, runs locally): `claude mcp add mars-time -- npx -y github:InterImm/mars-clock#gh-pages`, or `{"command": "npx", "args": ["-y", "github:InterImm/mars-clock#gh-pages"]}` in any MCP config. Tools: `get_mars_time`, `interimm_to_earth`, `mission_sol`, `sol_to_earth`, `mars_year_dates`, `light_message`, `interstellar_voyage`.
- **Shell**: `npx -y github:InterImm/mars-clock#gh-pages now [--utc ISO] [--longitude deg_east] [--zone 0-24]` prints JSON.
- **[llms.txt](llms.txt)** and **[llms-full.txt](llms-full.txt)** (the guide plus every formula and the library source; regenerated by `npm run build:api`).
- **[OpenAPI](api/v1/openapi.json)** description of the JSON endpoints.
@@ -83,6 +106,7 @@ Static files, regenerated from the library by `npm run build:api` (CI fails if t
| `api/v1/interimm-years.json` | Start and length of InterImm years 1–80, and the month names |
| `api/v1/missions.json` | Landing times and longitudes |
| `api/v1/leap-seconds.json` | The leap-second table |
+| `api/v1/stars.json` | Nearby stars, distance in light years, trip times for each ship profile |
```sh
curl -s https://interimm.org/mars-clock/api/v1/daily/2026.json | jq '.rows[275]'
diff --git a/api/v1/index.json b/api/v1/index.json
index 7aa477a..2a06252 100644
--- a/api/v1/index.json
+++ b/api/v1/index.json
@@ -1 +1 @@
-{"name":"InterImm Mars time API","version":"1.0.0","docs":"https://interimm.org/mars-clock/en/#api","library":"https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/marstime.js","endpoints":{"daily":"daily/{year}.json for 2000-2050: one row per UTC day","marsYears":"mars-years.json","interimmYears":"interimm-years.json","missions":"missions.json","leapSeconds":"leap-seconds.json"},"openapi":"openapi.json","llms":"https://interimm.org/mars-clock/llms.txt","mcp":"npx -y github:InterImm/mars-clock#gh-pages"}
+{"name":"InterImm Mars time API","version":"1.0.0","docs":"https://interimm.org/mars-clock/en/#api","library":"https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/marstime.js","endpoints":{"daily":"daily/{year}.json for 2000-2050: one row per UTC day","marsYears":"mars-years.json","interimmYears":"interimm-years.json","missions":"missions.json","leapSeconds":"leap-seconds.json","stars":"stars.json: nearby stars, light time and trip times"},"lightyear":"https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/lightyear.js","openapi":"openapi.json","llms":"https://interimm.org/mars-clock/llms.txt","mcp":"npx -y github:InterImm/mars-clock#gh-pages"}
diff --git a/api/v1/openapi.json b/api/v1/openapi.json
index 889b81d..0fd0afc 100644
--- a/api/v1/openapi.json
+++ b/api/v1/openapi.json
@@ -1 +1 @@
-{"openapi":"3.1.0","info":{"title":"InterImm Mars time API","version":"1.0.0","description":"Static, precomputed Mars time tables (GET only, no key, CORS open). For an arbitrary instant use the JavaScript library (https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/marstime.js) or the MCP server (npx -y github:InterImm/mars-clock#gh-pages). Guide for language models: https://interimm.org/mars-clock/llms.txt","license":{"name":"MIT","identifier":"MIT"}},"servers":[{"url":"https://interimm.org/mars-clock/api/v1"}],"paths":{"/daily/{year}.json":{"get":{"operationId":"getDailyTable","summary":"Mars time at 00:00 UTC on every day of an Earth year (2000-2050)","parameters":[{"name":"year","in":"path","required":true,"schema":{"type":"integer","minimum":2000,"maximum":2050}}],"responses":{"200":{"description":"Rows are arrays in the order of `columns`.","content":{"application/json":{"schema":{"type":"object","properties":{"about":{"type":"string"},"columns":{"type":"array","items":{"type":"string"},"example":["utc","msd","mtc","ls","marsYear","interimmDate","interimmClock","curiositySol","perseveranceSol"]},"rows":{"type":"array","items":{"type":"array","prefixItems":[{"type":"string","description":"UTC date"},{"type":"number","description":"Mars Sol Date"},{"type":"string","description":"Coordinated Mars Time hh:mm:ss"},{"type":"number","description":"Solar longitude Ls, degrees"},{"type":"integer","description":"Mars Year (Clancy)"},{"type":"string","description":"InterImm date year-MM-DD, zone 0"},{"type":"string","description":"InterImm clock, zone 0"},{"type":["integer","null"],"description":"Curiosity sol"},{"type":["integer","null"],"description":"Perseverance sol"}]}}}}}}}}}},"/mars-years.json":{"get":{"operationId":"getMarsYears","summary":"Start of Mars Years 1-80 and their Ls 90/180/270 dates","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"about":{"type":"string"},"years":{"type":"array","items":{"type":"object","properties":{"marsYear":{"type":"integer"},"start":{"type":"string"},"ls90":{"type":"string"},"ls180":{"type":"string"},"ls270":{"type":"string"}}}}}}}}}}}},"/interimm-years.json":{"get":{"operationId":"getInterimmYears","summary":"Start and length of InterImm calendar years 1-80, and month names","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"about":{"type":"string"},"months":{"type":"array","items":{"type":"object","properties":{"month":{"type":"integer"},"zh":{"type":"string"},"en":{"type":"string"}}}},"years":{"type":"array","items":{"type":"object","properties":{"year":{"type":"integer"},"start":{"type":"string"},"sols":{"type":"integer"}}}}}}}}}}}},"/missions.json":{"get":{"operationId":"getMissions","summary":"Landers and rovers: landing time (UTC) and longitude (degrees east)","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/leap-seconds.json":{"get":{"operationId":"getLeapSeconds","summary":"TAI-UTC table used for the conversion","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}}}}
+{"openapi":"3.1.0","info":{"title":"InterImm Mars time API","version":"1.0.0","description":"Static, precomputed Mars time tables (GET only, no key, CORS open). For an arbitrary instant use the JavaScript library (https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/marstime.js) or the MCP server (npx -y github:InterImm/mars-clock#gh-pages). Guide for language models: https://interimm.org/mars-clock/llms.txt","license":{"name":"MIT","identifier":"MIT"}},"servers":[{"url":"https://interimm.org/mars-clock/api/v1"}],"paths":{"/daily/{year}.json":{"get":{"operationId":"getDailyTable","summary":"Mars time at 00:00 UTC on every day of an Earth year (2000-2050)","parameters":[{"name":"year","in":"path","required":true,"schema":{"type":"integer","minimum":2000,"maximum":2050}}],"responses":{"200":{"description":"Rows are arrays in the order of `columns`.","content":{"application/json":{"schema":{"type":"object","properties":{"about":{"type":"string"},"columns":{"type":"array","items":{"type":"string"},"example":["utc","msd","mtc","ls","marsYear","interimmDate","interimmClock","curiositySol","perseveranceSol"]},"rows":{"type":"array","items":{"type":"array","prefixItems":[{"type":"string","description":"UTC date"},{"type":"number","description":"Mars Sol Date"},{"type":"string","description":"Coordinated Mars Time hh:mm:ss"},{"type":"number","description":"Solar longitude Ls, degrees"},{"type":"integer","description":"Mars Year (Clancy)"},{"type":"string","description":"InterImm date year-MM-DD, zone 0"},{"type":"string","description":"InterImm clock, zone 0"},{"type":["integer","null"],"description":"Curiosity sol"},{"type":["integer","null"],"description":"Perseverance sol"}]}}}}}}}}}},"/mars-years.json":{"get":{"operationId":"getMarsYears","summary":"Start of Mars Years 1-80 and their Ls 90/180/270 dates","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"about":{"type":"string"},"years":{"type":"array","items":{"type":"object","properties":{"marsYear":{"type":"integer"},"start":{"type":"string"},"ls90":{"type":"string"},"ls180":{"type":"string"},"ls270":{"type":"string"}}}}}}}}}}}},"/interimm-years.json":{"get":{"operationId":"getInterimmYears","summary":"Start and length of InterImm calendar years 1-80, and month names","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"about":{"type":"string"},"months":{"type":"array","items":{"type":"object","properties":{"month":{"type":"integer"},"zh":{"type":"string"},"en":{"type":"string"}}}},"years":{"type":"array","items":{"type":"object","properties":{"year":{"type":"integer"},"start":{"type":"string"},"sols":{"type":"integer"}}}}}}}}}}}},"/missions.json":{"get":{"operationId":"getMissions","summary":"Landers and rovers: landing time (UTC) and longitude (degrees east)","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/stars.json":{"get":{"operationId":"getStars","summary":"Nearby stars: distance in light years and relativistic trip times per ship profile","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/leap-seconds.json":{"get":{"operationId":"getLeapSeconds","summary":"TAI-UTC table used for the conversion","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}}}}
diff --git a/api/v1/stars.json b/api/v1/stars.json
new file mode 100644
index 0000000..dab612c
--- /dev/null
+++ b/api/v1/stars.json
@@ -0,0 +1 @@
+{"about":"Nearby stars: distance from the Sun in light years (= one-way light time in years, from Earth or Mars), and trip times for each ship profile in years (earthYears in the Sun/Mars frame, shipYears aboard).","storyOffsetDays":70491,"profiles":{"sail":{"name":{"en":"Light-sail probe (flyby)","zh":"光帆探测器(飞掠)"},"accel":1000,"cruise":0.2,"decelerate":false},"ark":{"name":{"en":"Fusion ark","zh":"聚变方舟"},"accel":0.05,"cruise":0.1,"decelerate":true},"fast":{"name":{"en":"Beamed sail, crewed","zh":"载人光帆"},"accel":0.3,"cruise":0.3,"decelerate":true},"torch":{"name":{"en":"Torch ship at 1 g","zh":"1 g 火炬船"},"accel":1,"cruise":null,"decelerate":true}},"stars":[{"id":"proxima","name":{"en":"Proxima Centauri","zh":"比邻星"},"distance":4.2465,"type":"M5.5V","planets":true,"voyages":{"sail":{"earthYears":21.2326,"shipYears":20.8036,"peakBeta":0.2},"ark":{"earthYears":44.4073,"shipYears":44.1977,"peakBeta":0.1},"fast":{"earthYears":15.1466,"shipYears":14.5104,"peakBeta":0.3},"torch":{"earthYears":5.8726,"shipYears":3.5421,"peakBeta":0.9497}}},{"id":"alpha-centauri","name":{"en":"Alpha Centauri A/B","zh":"南门二(半人马座α)"},"distance":4.37,"type":"G2V+K1V","voyages":{"sail":{"earthYears":21.8501,"shipYears":21.4086,"peakBeta":0.2},"ark":{"earthYears":45.6423,"shipYears":45.4265,"peakBeta":0.1},"fast":{"earthYears":15.5582,"shipYears":14.9031,"peakBeta":0.3},"torch":{"earthYears":6.0025,"shipYears":3.5824,"peakBeta":0.9517}}},{"id":"barnard","name":{"en":"Barnard's Star","zh":"巴纳德星"},"distance":5.96,"type":"M4V","planets":true,"voyages":{"sail":{"earthYears":29.8001,"shipYears":29.198,"peakBeta":0.2},"ark":{"earthYears":61.5423,"shipYears":61.2468,"peakBeta":0.1},"fast":{"earthYears":20.8582,"shipYears":19.9589,"peakBeta":0.3},"torch":{"earthYears":7.6561,"shipYears":4.0355,"peakBeta":0.9694}}},{"id":"wolf-359","name":{"en":"Wolf 359","zh":"沃尔夫359"},"distance":7.86,"type":"M6V","voyages":{"sail":{"earthYears":39.3001,"shipYears":38.5061,"peakBeta":0.2},"ark":{"earthYears":80.5423,"shipYears":80.1516,"peakBeta":0.1},"fast":{"earthYears":27.1916,"shipYears":26.0006,"peakBeta":0.3},"torch":{"earthYears":9.604,"shipYears":4.4638,"peakBeta":0.9803}}},{"id":"lalande-21185","name":{"en":"Lalande 21185","zh":"拉兰德21185"},"distance":8.31,"type":"M2V","planets":true,"voyages":{"sail":{"earthYears":41.5501,"shipYears":40.7106,"peakBeta":0.2},"ark":{"earthYears":85.0423,"shipYears":84.629,"peakBeta":0.1},"fast":{"earthYears":28.6916,"shipYears":27.4315,"peakBeta":0.3},"torch":{"earthYears":10.0626,"shipYears":4.5525,"peakBeta":0.982}}},{"id":"sirius","name":{"en":"Sirius","zh":"天狼星"},"distance":8.6,"type":"A1V+DA2","voyages":{"sail":{"earthYears":43.0001,"shipYears":42.1313,"peakBeta":0.2},"ark":{"earthYears":87.9423,"shipYears":87.5145,"peakBeta":0.1},"fast":{"earthYears":29.6582,"shipYears":28.3536,"peakBeta":0.3},"torch":{"earthYears":10.3578,"shipYears":4.6075,"peakBeta":0.983}}},{"id":"epsilon-eridani","name":{"en":"Epsilon Eridani","zh":"天苑四(波江座ε)"},"distance":10.47,"type":"K2V","planets":true,"voyages":{"sail":{"earthYears":52.3501,"shipYears":51.2924,"peakBeta":0.2},"ark":{"earthYears":106.6423,"shipYears":106.1207,"peakBeta":0.1},"fast":{"earthYears":35.8916,"shipYears":34.2998,"peakBeta":0.3},"torch":{"earthYears":12.2552,"shipYears":4.9287,"peakBeta":0.9877}}},{"id":"procyon","name":{"en":"Procyon","zh":"南河三"},"distance":11.46,"type":"F5IV+DQZ","voyages":{"sail":{"earthYears":57.3001,"shipYears":56.1424,"peakBeta":0.2},"ark":{"earthYears":116.5423,"shipYears":115.9711,"peakBeta":0.1},"fast":{"earthYears":39.1916,"shipYears":37.4478,"peakBeta":0.3},"torch":{"earthYears":13.2566,"shipYears":5.0791,"peakBeta":0.9895}}},{"id":"tau-ceti","name":{"en":"Tau Ceti","zh":"天仓五(鲸鱼座τ)"},"distance":11.91,"type":"G8V","planets":true,"voyages":{"sail":{"earthYears":59.5501,"shipYears":58.3469,"peakBeta":0.2},"ark":{"earthYears":121.0423,"shipYears":120.4486,"peakBeta":0.1},"fast":{"earthYears":40.6916,"shipYears":38.8787,"peakBeta":0.3},"torch":{"earthYears":13.7112,"shipYears":5.1438,"peakBeta":0.9902}}},{"id":"teegarden","name":{"en":"Teegarden's Star","zh":"蒂加登星"},"distance":12.5,"type":"M7V","planets":true,"voyages":{"sail":{"earthYears":62.5001,"shipYears":61.2373,"peakBeta":0.2},"ark":{"earthYears":126.9423,"shipYears":126.319,"peakBeta":0.1},"fast":{"earthYears":42.6582,"shipYears":40.7548,"peakBeta":0.3},"torch":{"earthYears":14.3068,"shipYears":5.2254,"peakBeta":0.991}}},{"id":"altair","name":{"en":"Altair","zh":"牛郎星(河鼓二)"},"distance":16.73,"type":"A7V","voyages":{"sail":{"earthYears":83.6501,"shipYears":81.96,"peakBeta":0.2},"ark":{"earthYears":169.2423,"shipYears":168.407,"peakBeta":0.1},"fast":{"earthYears":56.7582,"shipYears":54.2054,"peakBeta":0.3},"torch":{"earthYears":18.5666,"shipYears":5.7268,"peakBeta":0.9946}}},{"id":"vega","name":{"en":"Vega","zh":"织女星"},"distance":25.04,"type":"A0V","voyages":{"sail":{"earthYears":125.2001,"shipYears":122.6705,"peakBeta":0.2},"ark":{"earthYears":252.3423,"shipYears":251.0904,"peakBeta":0.1},"fast":{"earthYears":84.4582,"shipYears":80.6295,"peakBeta":0.3},"torch":{"earthYears":26.9078,"shipYears":6.4429,"peakBeta":0.9974}}},{"id":"trappist-1","name":{"en":"TRAPPIST-1","zh":"TRAPPIST-1"},"distance":40.66,"type":"M8V","planets":true,"voyages":{"sail":{"earthYears":203.3001,"shipYears":199.1926,"peakBeta":0.2},"ark":{"earthYears":408.5423,"shipYears":406.5074,"peakBeta":0.1},"fast":{"earthYears":136.5249,"shipYears":130.2979,"peakBeta":0.3},"torch":{"earthYears":42.5533,"shipYears":7.3294,"peakBeta":0.999}}},{"id":"betelgeuse","name":{"en":"Betelgeuse","zh":"参宿四"},"distance":548,"type":"M1-2Ia","note":"distance uncertain, 500-650 ly","voyages":{"sail":{"earthYears":2740.0001,"shipYears":2684.6409,"peakBeta":0.2},"ark":{"earthYears":5481.9423,"shipYears":5454.4767,"peakBeta":0.1},"fast":{"earthYears":1827.6582,"shipYears":1743.5363,"peakBeta":0.3},"torch":{"earthYears":549.934,"shipYears":12.2864,"peakBeta":1}}}]}
diff --git a/app/lightyear.css b/app/lightyear.css
new file mode 100644
index 0000000..7b7d62e
--- /dev/null
+++ b/app/lightyear.css
@@ -0,0 +1,45 @@
+/* Light-year page: only what it adds to the kit and clock.css. */
+
+.ly-hero .hero-mars {
+ /* a far, cool star instead of Mars */
+ background: radial-gradient(circle at 50% 50%, #fff6e0 0%, #ffd27a 12%, #e98a3a 30%, rgb(120 60 20 / 0.4) 55%, transparent 72%);
+ box-shadow: none; opacity: 0.45;
+}
+.ly-hero .clock-hero { font-size: clamp(2.6rem, 1.2rem + 7vw, 6rem); }
+
+textarea {
+ width: 100%; min-height: 8rem; padding: 0.6rem 0.75rem; border: 1px solid var(--border-strong); border-radius: 0.6rem;
+ background: var(--surface); color: var(--text); font: inherit; font-size: 1rem; resize: vertical;
+}
+textarea:focus-visible, input[type="date"]:focus-visible { outline: 3px solid var(--focus); outline-offset: 1px; }
+input[type="date"] {
+ min-height: 2.5rem; padding: 0.4rem 0.75rem; border: 1px solid var(--border-strong); border-radius: 0.6rem;
+ background: var(--surface); color: var(--text); font: inherit; font-size: 1rem;
+}
+
+.write-wrap { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr)); align-items: start; }
+.write-wrap form { gap: 1rem; }
+.outbox { display: grid; gap: 1rem; }
+.letter { gap: 0.4rem; }
+.letter.is-shared { border-color: var(--accent); margin-bottom: 1.5rem; }
+.letter-text { margin: 0.25rem 0 0.5rem; font-family: var(--font-display); font-size: 1.15rem; white-space: pre-wrap; overflow-wrap: anywhere; }
+.letter-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
+.progress { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
+.progress span { display: block; height: 100%; width: 0; min-width: 2px; background: var(--accent); }
+
+.voyage-controls { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); align-items: end; margin-bottom: 0.5rem; }
+.voyage-controls .check { min-height: 2.5rem; }
+.chart-wrap { margin: 1.5rem 0 0.5rem; }
+.chart { width: 100%; max-width: 52rem; height: auto; display: block; }
+.chart .axis line { stroke: var(--border); }
+.chart .axis text, .chart .mars-label { fill: var(--muted); font: 12px var(--font-mono); }
+.chart .mars-line { stroke: var(--muted); stroke-dasharray: 4 4; stroke-width: 1.5; }
+.chart .ship-line { fill: none; stroke: var(--accent); stroke-width: 2.5; }
+.chart .ship-label { fill: var(--accent); font: 600 12px var(--font-mono); }
+.chart .now-line { stroke: var(--border-strong); }
+.chart .ship-dot { fill: var(--accent); stroke: var(--bg); stroke-width: 2; }
+.scrub { width: 100%; accent-color: var(--accent); margin: 0.5rem 0 1rem; }
+
+.link-button { padding: 0; border: 0; background: none; color: var(--text); font: inherit; font-weight: 600; text-align: start; cursor: pointer; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
+.link-button:hover { text-decoration-color: var(--accent); }
+.data-table tr.is-current > * { background: var(--accent-soft); }
diff --git a/app/lightyear.js b/app/lightyear.js
new file mode 100644
index 0000000..1435226
--- /dev/null
+++ b/app/lightyear.js
@@ -0,0 +1,318 @@
+// The light-year page: story clock, messages to stars and voyage clocks. Physics in ../lib/lightyear.js.
+import * as mt from '../lib/marstime.js';
+import * as ly from '../lib/lightyear.js';
+
+const lang = document.documentElement.lang.startsWith('zh') ? 'zh' : 'en';
+const T = {
+ en: {
+ ly: (x) => `${fmtNum(x)} ly`,
+ years: (x) => `${fmtNum(x)} yr`,
+ yearsDays: (y) => { const whole = Math.floor(y); const d = Math.floor((y - whole) * 365.25); return `${whole} yr ${d} d`; },
+ c: (b) => `${b < 0.999 ? b.toFixed(3) : b.toFixed(6)} c`,
+ custom: 'Custom ship',
+ inTransit: 'In transit', arrived: 'Arrived', remove: 'Remove', share: 'Copy link', copied: 'Copied',
+ sentLine: (s, a, r) => `Sent ${s} · arrives ${a} · earliest reply ${r}`,
+ travelled: (x, d) => `${x} of ${d} light years`,
+ km: (k) => `${k} km out`,
+ outboxEmpty: 'Nothing sent yet from this browser.',
+ shared: 'A message someone sent you a link to',
+ empty: 'Write something first.',
+ zh: false,
+ },
+ zh: {
+ ly: (x) => `${fmtNum(x)} 光年`,
+ years: (x) => `${fmtNum(x)} 年`,
+ yearsDays: (y) => { const whole = Math.floor(y); const d = Math.floor((y - whole) * 365.25); return `${whole} 年 ${d} 天`; },
+ c: (b) => `${b < 0.999 ? b.toFixed(3) : b.toFixed(6)} 倍光速`,
+ custom: '自定义飞船',
+ inTransit: '在路上', arrived: '已抵达', remove: '删除', share: '复制链接', copied: '已复制',
+ sentLine: (s, a, r) => `发出 ${s} · 抵达 ${a} · 最早回信 ${r}`,
+ travelled: (x, d) => `已走 ${x} / ${d} 光年`,
+ km: (k) => `已飞出 ${k} 千米`,
+ outboxEmpty: '这个浏览器还没有发出过信。',
+ shared: '有人发给你的一封信',
+ empty: '先写点什么。',
+ zh: true,
+ },
+}[lang];
+
+const $ = (id) => document.getElementById(id);
+const set = (id, text) => { const el = $(id); if (el && el.textContent !== text) el.textContent = text; };
+const store = {
+ get(k, d) { try { return localStorage.getItem(k) ?? d; } catch { return d; } },
+ set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
+};
+function fmtNum(x) {
+ const n = x >= 100 ? 0 : x >= 10 ? 1 : 2;
+ return x.toLocaleString(lang === 'zh' ? 'zh-CN' : 'en', { maximumFractionDigits: n, minimumFractionDigits: 0 });
+}
+
+// ---------------------------------------------------------------- calendar: story (2219) or real
+
+const calSelect = $('calendar');
+calSelect.value = store.get('lightyear.calendar', 'story');
+const story = () => calSelect.value === 'story';
+const shift = (ms) => (story() ? ms + ly.STORY_OFFSET_DAYS * 86400e3 : ms);
+const unshift = (ms) => (story() ? ms - ly.STORY_OFFSET_DAYS * 86400e3 : ms);
+const day = (ms) => new Date(shift(ms)).toISOString().slice(0, 10);
+const marsDate = (ms) => mt.interimmTime(shift(ms)).iso; // the InterImm calendar, read on the chosen calendar
+
+// ---------------------------------------------------------------- star picker
+
+const starSelect = $('star');
+for (const s of ly.STARS) starSelect.add(new Option(`${s.name[lang]} · ${T.ly(s.distance)}`, s.id));
+starSelect.value = store.get('lightyear.star', 'epsilon-eridani');
+if (!starSelect.value) starSelect.value = 'epsilon-eridani';
+const currentStar = () => ly.star(starSelect.value);
+
+// ---------------------------------------------------------------- hero
+
+function hero() {
+ const now = Date.now();
+ const s = currentStar();
+ const iso = new Date(shift(now)).toISOString();
+ set('hero-clock', iso.slice(11, 19));
+ set('hero-date', `${iso.slice(0, 10)} UTC · ${lang === 'zh' ? '星际移民历' : 'InterImm'} ${marsDate(now)}`);
+ set('hero-distance', T.ly(s.distance));
+ set('hero-arrives', day(ly.message(now, s.distance).arrives.getTime()));
+ set('hero-light', day(ly.emitted(now, s.distance).getTime()));
+}
+
+// ---------------------------------------------------------------- messages
+
+const OUTBOX_KEY = 'lightyear.outbox';
+const readOutbox = () => { try { return JSON.parse(store.get(OUTBOX_KEY, '[]')) || []; } catch { return []; } };
+const writeOutbox = (list) => store.set(OUTBOX_KEY, JSON.stringify(list.slice(0, 50)));
+
+const b64 = {
+ enc: (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
+ dec: (s) => new TextDecoder().decode(Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))),
+};
+const shareUrl = (m) => {
+ const u = new URL(location.href.split(/[?#]/)[0]);
+ u.searchParams.set('to', m.to);
+ u.searchParams.set('at', String(m.at));
+ if (m.reply) u.searchParams.set('r', String(m.reply));
+ u.searchParams.set('m', b64.enc(m.text));
+ return u.toString() + '#write';
+};
+
+function letterCard(m, { removable }) {
+ const s = ly.star(m.to);
+ const card = document.createElement('article');
+ card.className = 'card letter';
+ const head = document.createElement('p');
+ head.className = 'tile-kicker';
+ head.textContent = `→ ${s.name[lang]}`;
+ const body = document.createElement('p');
+ body.className = 'letter-text';
+ body.textContent = m.text;
+ const times = ly.message(m.at, s.distance, { replyAfter: m.reply || 0 });
+ const line = document.createElement('p');
+ line.className = 'note mono';
+ line.textContent = T.sentLine(day(m.at), day(times.arrives.getTime()), day(times.earliestReply.getTime()));
+ const bar = document.createElement('div');
+ bar.className = 'progress';
+ bar.innerHTML = '';
+ const status = document.createElement('p');
+ status.className = 'note mono';
+ const actions = document.createElement('div');
+ actions.className = 'letter-actions';
+ const copy = document.createElement('button');
+ copy.type = 'button'; copy.className = 'btn btn-ghost'; copy.textContent = T.share;
+ copy.addEventListener('click', async () => {
+ try { await navigator.clipboard.writeText(shareUrl(m)); copy.textContent = T.copied; setTimeout(() => (copy.textContent = T.share), 1500); }
+ catch { prompt('', shareUrl(m)); }
+ });
+ actions.append(copy);
+ if (removable) {
+ const del = document.createElement('button');
+ del.type = 'button'; del.className = 'btn btn-ghost'; del.textContent = T.remove;
+ del.addEventListener('click', () => { writeOutbox(readOutbox().filter((x) => !(x.at === m.at && x.text === m.text))); renderOutbox(); });
+ actions.append(del);
+ }
+ card.append(head, body, line, bar, status, actions);
+ card.update = (now) => {
+ const p = ly.messageProgress(m.at, s.distance, now);
+ bar.firstChild.style.width = `${(p.fraction * 100).toFixed(4)}%`;
+ const km = Math.floor(p.travelled * ly.LY_M / 1000).toLocaleString(lang === 'zh' ? 'zh-CN' : 'en');
+ status.textContent = p.arrived ? `${T.arrived} · ${T.travelled(fmtNum(s.distance), fmtNum(s.distance))}`
+ : `${T.inTransit} · ${T.km(km)} · ${T.travelled(p.travelled.toFixed(8), fmtNum(s.distance))}`;
+ };
+ return card;
+}
+
+let liveCards = [];
+function renderOutbox() {
+ const box = $('outbox');
+ box.replaceChildren();
+ liveCards = liveCards.filter((c) => c.shared);
+ const list = readOutbox();
+ if (!list.length) { const p = document.createElement('p'); p.className = 'note'; p.textContent = T.outboxEmpty; box.append(p); }
+ for (const m of list) { const c = letterCard(m, { removable: true }); box.append(c); liveCards.push(c); }
+ tickLetters();
+}
+
+function tickLetters() { const now = Date.now(); for (const c of liveCards) c.update(now); }
+
+$('write-form').addEventListener('submit', (e) => {
+ e.preventDefault();
+ const text = $('letter').value.trim();
+ if (!text) { set('write-note', T.empty); return; }
+ set('write-note', '');
+ const m = { to: starSelect.value, at: Date.now(), text, reply: Number($('reply-after').value) || 0 };
+ writeOutbox([m, ...readOutbox()]);
+ $('letter').value = '';
+ renderOutbox();
+});
+
+// A shared link: ?to=star&at=ms&m=base64url(text)&r=years
+(function sharedLetter() {
+ const q = new URLSearchParams(location.search);
+ if (!q.get('m') || !q.get('to') || !q.get('at')) return;
+ try {
+ const m = { to: q.get('to'), at: Number(q.get('at')), text: b64.dec(q.get('m')).slice(0, 2000), reply: Number(q.get('r')) || 0 };
+ ly.star(m.to);
+ if (!Number.isFinite(m.at)) return;
+ const c = letterCard(m, { removable: false });
+ c.shared = true;
+ c.classList.add('is-shared');
+ const label = document.createElement('p');
+ label.className = 'kicker'; label.textContent = T.shared;
+ $('shared').append(label, c);
+ $('shared').hidden = false;
+ liveCards.push(c);
+ } catch { /* a broken link shows nothing */ }
+})();
+
+// ---------------------------------------------------------------- voyage clock
+
+const profileSelect = $('profile');
+for (const [k, p] of Object.entries(ly.PROFILES)) profileSelect.add(new Option(p.name[lang], k));
+profileSelect.add(new Option(T.custom, 'custom'));
+const accelIn = $('accel'), cruiseIn = $('cruise'), decelIn = $('decel'), departIn = $('depart'), scrub = $('scrub');
+
+function fillProfile() {
+ const p = ly.PROFILES[profileSelect.value];
+ if (!p) return;
+ accelIn.value = p.accel; cruiseIn.value = p.cruise ?? ''; decelIn.checked = p.decelerate;
+ set('profile-note', p.note[lang]);
+}
+profileSelect.value = store.get('lightyear.profile', 'torch');
+if (!profileSelect.value) profileSelect.value = 'torch';
+fillProfile();
+for (const el of [accelIn, cruiseIn, decelIn]) el.addEventListener('input', () => { profileSelect.value = 'custom'; set('profile-note', ''); voyageView(); });
+profileSelect.addEventListener('change', () => { store.set('lightyear.profile', profileSelect.value); fillProfile(); voyageView(); renderTable(); });
+
+const setDepartDefault = () => { departIn.value = day(Date.now()); };
+setDepartDefault();
+
+let current = null;
+function voyageView() {
+ const s = currentStar();
+ const cruise = cruiseIn.value === '' ? null : Number(cruiseIn.value);
+ let v;
+ try {
+ v = ly.voyage({ distance: s.distance, accel: Number(accelIn.value) || 0, cruise, decelerate: decelIn.checked });
+ set('voyage-error', '');
+ } catch (e) { set('voyage-error', e.message); return; }
+ const depart = unshift(Date.parse(`${departIn.value}T00:00:00Z`));
+ if (!Number.isFinite(depart)) return;
+ const dates = ly.voyageDates(v, depart);
+ current = { v, depart };
+ set('v-earth', T.yearsDays(v.earthYears));
+ set('v-ship', T.yearsDays(v.shipYears));
+ set('v-saved', T.yearsDays(v.timeSavedYears));
+ set('v-peak', T.c(v.peakBeta));
+ set('v-gamma', `γ = ${v.peakGamma < 100 ? v.peakGamma.toFixed(3) : fmtNum(v.peakGamma)}`);
+ set('v-arrive', `${day(dates.arrives.getTime())} · ${marsDate(dates.arrives.getTime())}`);
+ set('v-news', day(dates.newsHome.getTime()));
+ set('v-mass', v.photonRocketMassRatio < 1e6 ? fmtNum(v.photonRocketMassRatio) : v.photonRocketMassRatio.toExponential(2));
+ set('v-mars-years', T.years(v.marsYears));
+ drawChart(v);
+ scrubView();
+}
+
+function scrubView() {
+ if (!current) return;
+ const { v, depart } = current;
+ const t = (Number(scrub.value) / 1000) * v.earthYears;
+ const st = ly.voyageAt(v, t);
+ const when = depart + t * ly.YEAR_MS;
+ set('s-date', `${day(when)} · ${marsDate(when)}`);
+ set('s-earth', T.yearsDays(t));
+ set('s-ship', T.yearsDays(st.shipYears));
+ set('s-distance', T.ly(st.distance));
+ set('s-speed', T.c(st.beta));
+ const dot = $('chart-dot');
+ if (dot) { dot.setAttribute('cx', X(t, v)); dot.setAttribute('cy', Y(st.shipYears, v)); }
+ const line = $('chart-now');
+ if (line) { line.setAttribute('x1', X(t, v)); line.setAttribute('x2', X(t, v)); }
+}
+
+// Chart: Mars-frame years across, ship-clock years up, with the Mars clock as the diagonal.
+const W = 640, H = 300, PAD = { l: 48, r: 16, t: 16, b: 36 };
+const X = (t, v) => (PAD.l + (t / v.earthYears) * (W - PAD.l - PAD.r)).toFixed(1);
+const Y = (y, v) => (H - PAD.b - (y / v.earthYears) * (H - PAD.t - PAD.b)).toFixed(1);
+function drawChart(v) {
+ const pts = [];
+ for (let i = 0; i <= 240; i++) { const t = (v.earthYears * i) / 240; pts.push(`${X(t, v)},${Y(ly.voyageAt(v, t).shipYears, v)}`); }
+ const ticks = niceTicks(v.earthYears);
+ const lab = lang === 'zh' ? { x: '火星上过去的年数', ship: '船上的钟', mars: '火星的钟' } : { x: 'years passed on Mars', ship: 'ship clock', mars: 'Mars clock' };
+ $('chart').innerHTML = `
+
Use the embed builder above for a styled card, or <mars-clock> for bare text that takes your page's style:
<script type="module" src="https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/mars-clock-element.js"></script>
<mars-clock></mars-clock> <!-- or show="interimm" zone="5", show="sol" mission="perseverance" -->
+ When a message reaches a nearby star, and how many years pass aboard and on Mars during an interstellar trip: see the Light Years page and lib/lightyear.js.
Add the Mars time tools to Claude, Cursor or any MCP client. Nothing to install beyond Node:
claude mcp add mars-time -- npx -y github:InterImm/mars-clock#gh-pages
diff --git a/en/lightyear/index.html b/en/lightyear/index.html
new file mode 100644
index 0000000..56a28f5
--- /dev/null
+++ b/en/lightyear/index.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+Light Years | InterImm
+
+
+
+
+
+
+
+
+
+
+
+
+Skip to content
+
+
+
+ InterImm
+
+
+
+
+
+
+
+
+ Light Years · InterImm time service
+ 中文
+
+ Light Years: letters to the stars and a voyage clock
+
+
+
+
+
+
+
+ - Distance
- –
+ - A message sent today arrives
- –
+ - Tonight's starlight left in
- –
+
+
+
+
+
+
+
+ Write to a star
+ A message travels at light speed, one light year per year. Send it and watch it move out kilometre by kilometre; share the link and your friends can see how far it has got.
+
+
+
+
+
+ Sent
+
+
+
+
+
+
+
+
+
+ Voyage clock
+ The closer a ship gets to light speed, the slower its clock runs. Pick a ship and a departure date to see how many years pass on Mars and how much older the crew gets.
+
+
+
+
+
+
+
+
+
+
+
+
+ Passes on Mars
–About Mars years.
+ Passes aboard
–The crew ages less than the people they left.
+ Top speed
–
+ Arrival
–News of arrival reaches Mars:
+ Ideal photon rocket mass ratio
–Starting mass over arrival mass with light as the exhaust. A laser-pushed sail carries no fuel.
+
+
+
+
+
+
+ - Date
+ - Since departure (Mars)
+ - Ship clock
+ - Distance flown
+ - Speed
+
+
+
+
+
+
+
+ Nearby stars
+ Trips use the . Click a name to pick it. Procyon, Sirius and Betelgeuse cities on Mars are named after these stars.
+
+
+
+ Star Type Distance Question and answer On Mars Aboard
+
+
+
+
+
+
+
+
+ How it works
+ The ship accelerates at a constant felt acceleration (hyperbolic motion in special relativity), coasts at its cruise speed and brakes the same way before arrival. With no cruise speed it accelerates to the midpoint and flips. Mars and the Sun share one frame closely enough that time "on Mars" is solar-system time. Star distances are rounded from SIMBAD and Gaia.
+ The InterImm story calendar runs 70,491 days ahead of the real one: 3 October 2026 is 3 October 2219 in the story, the first year of terraforming phase 1.
+ All of it is one JavaScript file with no dependencies that you can use in your own project:
+import { voyage, message } from
+ 'https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/lightyear.js';
+
+voyage({ distance: 10.47, accel: 1 }) // 1 g to Epsilon Eridani
+// { earthYears: 12.26, shipYears: 4.93, peakBeta: 0.988, ... }
+ The star table with trip times for each ship is also JSON: stars.json. AI assistants can use the MCP tools light_message and interstellar_voyage; see the Mars Clock page.
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
index f3fa8a5..2d30453 100755
--- a/index.html
+++ b/index.html
@@ -237,6 +237,8 @@ 作为小组件
想要带样式的卡片,用上面的嵌入生成器;想要沿用你网页样式的纯文字,用 <mars-clock>:
<script type="module" src="https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/mars-clock-element.js"></script>
<mars-clock></mars-clock> <!-- 或 show="interimm" zone="5",show="sol" mission="perseverance" -->
+ 光年
+ 给邻近恒星的信什么时候到、星际航程中船上和火星上各过了多少年:见光年页面和 lib/lightyear.js。
给 AI 助手用
把火星时间工具加到 Claude、Cursor 或任何支持 MCP 的客户端,只需要 Node:
claude mcp add mars-time -- npx -y github:InterImm/mars-clock#gh-pages
diff --git a/lib/lightyear.js b/lib/lightyear.js
new file mode 100644
index 0000000..bc3087a
--- /dev/null
+++ b/lib/lightyear.js
@@ -0,0 +1,149 @@
+// Light-year time: messages and voyages to nearby stars, and the InterImm story clock.
+// One ES module, no dependencies. Units: light years (ly) and Julian years (yr), so c = 1 ly/yr.
+// Every function is pure and takes milliseconds since 1970 or a Date where it needs an instant.
+
+export const VERSION = '1.0.0';
+
+export const YEAR_MS = 365.25 * 86400e3; // Julian year
+export const LY_M = 9460730472580800; // metres in a light year (IAU)
+export const G = 9.80665 * (365.25 * 86400) ** 2 / LY_M; // 1 g in ly/yr^2, about 1.0323
+const MARS_YEAR_DAYS = 686.9796;
+
+// The InterImm story runs 70,491 days (about 193 years) ahead of the real calendar:
+// 2026-10-03 on Earth is 2219-10-03 in the story, the first year of terraforming phase 1.
+export const STORY_OFFSET_DAYS = 70491;
+const OFFSET_MS = STORY_OFFSET_DAYS * 86400e3;
+const ms = (d) => (d instanceof Date ? d.getTime() : Number(d));
+
+export const storyFromReal = (date) => new Date(ms(date) + OFFSET_MS);
+export const realFromStory = (date) => new Date(ms(date) - OFFSET_MS);
+
+// Nearby stars. Distances from the Sun in light years, rounded from SIMBAD/Gaia values; the Earth and Mars
+// are both within 0.00003 ly of the Sun, so the same numbers hold from either planet.
+export const STARS = [
+ { id: 'proxima', name: { en: 'Proxima Centauri', zh: '比邻星' }, distance: 4.2465, type: 'M5.5V', planets: true },
+ { id: 'alpha-centauri', name: { en: 'Alpha Centauri A/B', zh: '南门二(半人马座α)' }, distance: 4.37, type: 'G2V+K1V' },
+ { id: 'barnard', name: { en: "Barnard's Star", zh: '巴纳德星' }, distance: 5.96, type: 'M4V', planets: true },
+ { id: 'wolf-359', name: { en: 'Wolf 359', zh: '沃尔夫359' }, distance: 7.86, type: 'M6V' },
+ { id: 'lalande-21185', name: { en: 'Lalande 21185', zh: '拉兰德21185' }, distance: 8.31, type: 'M2V', planets: true },
+ { id: 'sirius', name: { en: 'Sirius', zh: '天狼星' }, distance: 8.60, type: 'A1V+DA2' },
+ { id: 'epsilon-eridani', name: { en: 'Epsilon Eridani', zh: '天苑四(波江座ε)' }, distance: 10.47, type: 'K2V', planets: true },
+ { id: 'procyon', name: { en: 'Procyon', zh: '南河三' }, distance: 11.46, type: 'F5IV+DQZ' },
+ { id: 'tau-ceti', name: { en: 'Tau Ceti', zh: '天仓五(鲸鱼座τ)' }, distance: 11.91, type: 'G8V', planets: true },
+ { id: 'teegarden', name: { en: "Teegarden's Star", zh: '蒂加登星' }, distance: 12.50, type: 'M7V', planets: true },
+ { id: 'altair', name: { en: 'Altair', zh: '牛郎星(河鼓二)' }, distance: 16.73, type: 'A7V' },
+ { id: 'vega', name: { en: 'Vega', zh: '织女星' }, distance: 25.04, type: 'A0V' },
+ { id: 'trappist-1', name: { en: 'TRAPPIST-1', zh: 'TRAPPIST-1' }, distance: 40.66, type: 'M8V', planets: true },
+ { id: 'betelgeuse', name: { en: 'Betelgeuse', zh: '参宿四' }, distance: 548, type: 'M1-2Ia', note: 'distance uncertain, 500-650 ly' },
+];
+
+export function star(idOrDistance) {
+ if (typeof idOrDistance === 'number') return { id: 'custom', name: { en: `${idOrDistance} ly`, zh: `${idOrDistance} 光年` }, distance: idOrDistance };
+ const s = STARS.find((x) => x.id === String(idOrDistance).toLowerCase());
+ if (!s) throw new Error(`Unknown star "${idOrDistance}". Known: ${STARS.map((x) => x.id).join(', ')}`);
+ return s;
+}
+
+// ---------------------------------------------------------------- messages at light speed
+
+// A message sent at `sent` reaches a star `distance` ly away one light-travel time later; an answer written
+// `replyAfter` years after it arrives gets back one light-travel time after that.
+export function message(sent, distance, { replyAfter = 0 } = {}) {
+ const t0 = ms(sent);
+ const arrives = t0 + distance * YEAR_MS;
+ const reply = arrives + replyAfter * YEAR_MS + distance * YEAR_MS;
+ return { sent: new Date(t0), arrives: new Date(arrives), earliestReply: new Date(reply), oneWayYears: distance, roundTripYears: 2 * distance + replyAfter };
+}
+
+// How far a message sent at `sent` has travelled by `now`, in ly and as a fraction of the way (0..1).
+export function messageProgress(sent, distance, now = Date.now()) {
+ const travelled = Math.max(0, (ms(now) - ms(sent)) / YEAR_MS);
+ return { travelled: Math.min(travelled, distance), fraction: Math.min(1, travelled / distance), arrived: travelled >= distance };
+}
+
+// Light received from a star at `received` left it one light-travel time earlier.
+export const emitted = (received, distance) => new Date(ms(received) - distance * YEAR_MS);
+
+// ---------------------------------------------------------------- voyages
+
+// A voyage: accelerate at `accel` (in g, felt aboard) up to `cruise` (fraction of c), coast, then brake the
+// same way if `decelerate`. With no cruise cap the ship accelerates to the midpoint and flips (or, without
+// braking, all the way). With accel 0 or null the ship is put at cruise speed instantly (pure coast).
+// Special relativity for constant proper acceleration (hyperbolic motion); times in the departure frame
+// (the Sun/Mars frame) are `earthYears`, times aboard are `shipYears`.
+export function voyage({ distance, accel = 1, cruise = null, decelerate = true }) {
+ if (!(distance > 0)) throw new Error('distance must be a positive number of light years');
+ if (cruise !== null && !(cruise > 0 && cruise < 1)) throw new Error('cruise must be between 0 and 1 (a fraction of light speed), or null');
+ const burns = decelerate ? 2 : 1;
+ const a = accel > 0 ? accel * G : 0;
+ if (!a && cruise === null) throw new Error('give a cruise speed when there is no acceleration');
+
+ let phi = cruise === null ? Infinity : Math.atanh(cruise); // rapidity at cruise
+ let burnDist = a ? (Math.cosh(phi) - 1) / a : 0;
+ if (a && burns * burnDist >= distance) { // never reaches cruise: accelerate to the midpoint (or the end) and flip
+ phi = Math.acosh(1 + (a * distance) / burns);
+ burnDist = distance / burns;
+ }
+ const beta = Math.tanh(phi), gamma = Math.cosh(phi);
+ const burnEarth = a ? Math.sinh(phi) / a : 0;
+ const burnShip = a ? phi / a : 0;
+ const coastDist = distance - burns * burnDist;
+ const coastEarth = coastDist / beta, coastShip = coastEarth / gamma;
+ const earthYears = burns * burnEarth + coastEarth;
+ const shipYears = burns * burnShip + coastShip;
+ return {
+ distance, accel: accel || 0, cruise, decelerate,
+ peakBeta: beta, peakGamma: gamma, rapidity: phi,
+ earthYears, shipYears, marsYears: earthYears * 365.25 / MARS_YEAR_DAYS,
+ timeSavedYears: earthYears - shipYears,
+ // Ideal photon rocket (exhaust at c): initial / final mass. A beamed sail carries no propellant.
+ photonRocketMassRatio: Math.exp(burns * phi),
+ phases: [
+ { phase: 'accelerate', earthYears: burnEarth, shipYears: burnShip, distance: burnDist },
+ { phase: 'coast', earthYears: coastEarth, shipYears: coastShip, distance: coastDist },
+ ...(decelerate ? [{ phase: 'decelerate', earthYears: burnEarth, shipYears: burnShip, distance: burnDist }] : []),
+ ],
+ };
+}
+
+// Where the ship is `t` Earth-frame years after departure: distance covered, speed, and time aboard.
+export function voyageAt(v, t) {
+ const a = v.accel * G;
+ const [acc, coast] = v.phases;
+ const T = v.earthYears;
+ if (t <= 0) return { t: 0, distance: 0, beta: 0, shipYears: 0, arrived: false };
+ if (t >= T) return { t, distance: v.distance, beta: v.decelerate ? 0 : v.peakBeta, shipYears: v.shipYears + (v.decelerate ? t - T : (t - T) / v.peakGamma), arrived: true };
+ const hyper = (s) => ({ x: (Math.sqrt(1 + (a * s) ** 2) - 1) / a, beta: (a * s) / Math.sqrt(1 + (a * s) ** 2), tau: Math.asinh(a * s) / a });
+ if (a && t < acc.earthYears) { const h = hyper(t); return { t, distance: h.x, beta: h.beta, shipYears: h.tau, arrived: false }; }
+ const tc = t - acc.earthYears;
+ if (tc < coast.earthYears) {
+ return { t, distance: acc.distance + tc * v.peakBeta, beta: v.peakBeta, shipYears: acc.shipYears + tc / v.peakGamma, arrived: false };
+ }
+ const h = hyper(T - t); // braking mirrors the first burn
+ return { t, distance: v.distance - h.x, beta: h.beta, shipYears: v.shipYears - h.tau, arrived: false };
+}
+
+// Ready-made ship designs for the tools. Speeds and accelerations are illustrative, in the range of published studies.
+export const PROFILES = {
+ sail: { name: { en: 'Light-sail probe (flyby)', zh: '光帆探测器(飞掠)' }, accel: 1000, cruise: 0.2, decelerate: false,
+ note: { en: 'Gram-scale probe pushed by a laser array, like Breakthrough Starshot. No braking.', zh: '激光阵列推动的克级探测器,类似“突破摄星”。不减速。' } },
+ ark: { name: { en: 'Fusion ark', zh: '聚变方舟' }, accel: 0.05, cruise: 0.1, decelerate: true,
+ note: { en: 'A crewed fusion ship at a tenth of light speed, braking at the far end.', zh: '载人聚变飞船,以十分之一光速巡航,到达时减速。' } },
+ fast: { name: { en: 'Beamed sail, crewed', zh: '载人光帆' }, accel: 0.3, cruise: 0.3, decelerate: true,
+ note: { en: 'A crewed sail pushed out from the solar system and braked by a magnetic sail.', zh: '从太阳系推出、靠磁帆减速的载人光帆。' } },
+ torch: { name: { en: 'Torch ship at 1 g', zh: '1 g 火炬船' }, accel: 1, cruise: null, decelerate: true,
+ note: { en: 'Constant 1 g all the way, flipping at the midpoint. Needs an antimatter-class engine.', zh: '全程 1 g 加速,中点掉头减速,需要反物质级的发动机。' } },
+};
+
+export function profileVoyage(profile, distance) {
+ const p = PROFILES[profile];
+ if (!p) throw new Error(`Unknown profile "${profile}". Known: ${Object.keys(PROFILES).join(', ')}`);
+ return voyage({ distance, accel: p.accel, cruise: p.cruise, decelerate: p.decelerate });
+}
+
+// Dates for a voyage leaving at `depart`: arrival in the departure frame, and the first news of arrival back home.
+export function voyageDates(v, depart) {
+ const d = ms(depart);
+ const arrives = d + v.earthYears * YEAR_MS;
+ return { depart: new Date(d), arrives: new Date(arrives), newsHome: new Date(arrives + v.distance * YEAR_MS) };
+}
diff --git a/lib/lightyear.test.mjs b/lib/lightyear.test.mjs
new file mode 100644
index 0000000..144f1d7
--- /dev/null
+++ b/lib/lightyear.test.mjs
@@ -0,0 +1,80 @@
+// Tests for lightyear.js. Run with: node --test lib/
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as ly from './lightyear.js';
+
+const near = (a, b, tol, msg) => assert.ok(Math.abs(a - b) <= tol, `${msg ?? ''} expected ${b} ± ${tol}, got ${a}`);
+
+test('1 g in light years per year squared', () => near(ly.G, 1.0323, 0.0001));
+
+test('story clock: 2026-10-03 is 2219-10-03', () => {
+ assert.equal(ly.storyFromReal(Date.UTC(2026, 9, 3)).toISOString().slice(0, 10), '2219-10-03');
+ assert.equal(ly.realFromStory(ly.storyFromReal(1.7e12)).getTime(), 1.7e12);
+});
+
+test('messages travel one year per light year', () => {
+ const m = ly.message(Date.UTC(2219, 9, 3), 10.47, { replyAfter: 1 });
+ near((m.arrives - m.sent) / ly.YEAR_MS, 10.47, 1e-9);
+ near((m.earliestReply - m.sent) / ly.YEAR_MS, 21.94, 1e-9);
+ const p = ly.messageProgress(m.sent, 10.47, m.sent.getTime() + 5 * ly.YEAR_MS);
+ near(p.travelled, 5, 1e-9);
+ assert.equal(p.arrived, false);
+ assert.equal(ly.messageProgress(m.sent, 10.47, m.arrives).arrived, true);
+ near((Date.UTC(2219, 9, 3) - ly.emitted(Date.UTC(2219, 9, 3), 4).getTime()) / ly.YEAR_MS, 4, 1e-9);
+});
+
+test('pure coast: time dilation by gamma', () => {
+ const v = ly.voyage({ distance: 10, accel: 0, cruise: 0.6 });
+ near(v.earthYears, 10 / 0.6, 1e-9);
+ near(v.shipYears, (10 / 0.6) * 0.8, 1e-9);
+});
+
+test('1 g brachistochrone to Epsilon Eridani matches the relativistic rocket equations', () => {
+ const v = ly.voyage({ distance: 10.47, accel: 1 });
+ // cosh(phi) = 1 + a d / 2; tau = 2 phi / a; t = 2 sinh(phi) / a
+ const phi = Math.acosh(1 + ly.G * 10.47 / 2);
+ near(v.shipYears, 2 * phi / ly.G, 1e-9);
+ near(v.earthYears, 2 * Math.sinh(phi) / ly.G, 1e-9);
+ near(v.shipYears, 4.92, 0.02);
+ near(v.earthYears, 12.25, 0.02);
+ assert.equal(v.phases[1].distance, 0);
+});
+
+test('long trips reach the cruise speed and coast', () => {
+ const v = ly.voyage({ distance: 10.47, accel: 0.05, cruise: 0.1 });
+ assert.ok(v.phases[1].distance > 9);
+ near(v.peakBeta, 0.1, 1e-12);
+ assert.ok(v.earthYears > 104.7 && v.earthYears < 110);
+ const sum = v.phases.reduce((s, p) => s + p.distance, 0);
+ near(sum, 10.47, 1e-9);
+});
+
+test('voyageAt is continuous and ends at the destination', () => {
+ for (const p of Object.keys(ly.PROFILES)) {
+ const v = ly.profileVoyage(p, 10.47);
+ let prev = ly.voyageAt(v, 0);
+ for (let i = 1; i <= 2000; i++) {
+ const s = ly.voyageAt(v, (v.earthYears * i) / 2000);
+ assert.ok(s.distance >= prev.distance - 1e-9, `${p} moves forward`);
+ assert.ok(s.distance - prev.distance <= v.earthYears / 2000 + 1e-9, `${p} slower than light`);
+ assert.ok(s.shipYears >= prev.shipYears - 1e-9, `${p} ship clock runs forward`);
+ prev = s;
+ }
+ near(prev.distance, 10.47, 1e-6, p);
+ near(prev.shipYears, v.shipYears, 1e-6, p);
+ }
+});
+
+test('voyageDates: news of arrival comes back one light-travel time later', () => {
+ const v = ly.profileVoyage('torch', 10.47);
+ const d = ly.voyageDates(v, Date.UTC(2219, 9, 3));
+ near((d.newsHome - d.arrives) / ly.YEAR_MS, 10.47, 1e-9);
+});
+
+test('bad input is refused', () => {
+ assert.throws(() => ly.voyage({ distance: -1 }));
+ assert.throws(() => ly.voyage({ distance: 1, cruise: 1.2 }));
+ assert.throws(() => ly.voyage({ distance: 1, accel: 0 }));
+ assert.throws(() => ly.star('nope'));
+ assert.equal(ly.star('epsilon-eridani').distance, 10.47);
+});
diff --git a/lightyear/index.html b/lightyear/index.html
new file mode 100644
index 0000000..8909486
--- /dev/null
+++ b/lightyear/index.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+光年 | 星际移民中心
+
+
+
+
+
+
+
+
+
+
+
+
+跳到正文
+
+
+
+ 星际移民中心
+
+
+
+
+
+
+
+
+
+
+
+
+ 给恒星写一封信
+ 信以光速飞行,一年走一光年。写好发出,它会在这里一千米一千米地往前走;把链接发给朋友,他们也能看到它走到了哪里。
+
+
+
+
+
+ 已发出
+
+
+
+
+
+
+
+
+
+ 航程钟
+ 飞得越接近光速,船上的钟走得越慢。选一种飞船和出发日期,看火星上过了多少年、船员又老了多少岁。
+
+
+
+
+
+
+
+
+
+
+
+
+ 火星上经过
–约 火星年。
+ 船上经过
–船员比留在火星的人少老 。
+ 最高速度
–
+ 抵达
–抵达的消息传回火星:
+ 理想光子火箭质量比
–出发质量除以到达质量,假设喷出的是光。被激光推动的光帆不带燃料。
+
+
+
+
+
+
+ - 火星日期
+ - 出发后(火星)
+ - 船上的钟
+ - 已飞
+ - 速度
+
+
+
+
+
+
+
+ 邻近的恒星
+ 航程按“”计算。点星名可以选它。南河城、天狼城、参宿城就是以这些恒星命名的。
+
+
+
+ 恒星 光谱型 距离 一问一答 火星上 船上
+
+
+
+
+
+
+
+
+ 怎么算的
+ 飞船按恒定的“体感”加速度加速(狭义相对论的双曲运动),到巡航速度后滑行,到达前用同样的加速度减速。没有设巡航速度时,飞船一直加速到中点再掉头。火星和太阳几乎静止于同一参考系,所以“火星上经过”的时间就是太阳系里的时间。恒星距离取自 SIMBAD 和 Gaia 并做了四舍五入。
+ 星际移民中心纪年比现实日历早 70,491 天:现实的 2026 年 10 月 3 日就是故事里的 2219 年 10 月 3 日,地球化第一阶段开始的那一年。
+ 这些计算都在一个没有依赖的 JavaScript 文件里,可以直接在你的项目中使用:
+import { voyage, message } from
+ 'https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/lightyear.js';
+
+voyage({ distance: 10.47, accel: 1 }) // 1 g 去天苑四
+// { earthYears: 12.26, shipYears: 4.93, peakBeta: 0.988, ... }
+ 恒星表和每种飞船的航程也有 JSON 版:stars.json。AI 助手可以用 MCP 工具 light_message 和 interstellar_voyage,见火星时钟页面。
+
+
+
+
+
+
+
diff --git a/llms-full.txt b/llms-full.txt
index bad1ed1..11d7c74 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -380,3 +380,157 @@ export function snapshot(date = Date.now(), { longitude = 0, zone = 0 } = {}) {
};
}
```
+
+## Light-year time (lib/lightyear.js)
+
+```js
+// Light-year time: messages and voyages to nearby stars, and the InterImm story clock.
+// One ES module, no dependencies. Units: light years (ly) and Julian years (yr), so c = 1 ly/yr.
+// Every function is pure and takes milliseconds since 1970 or a Date where it needs an instant.
+
+export const VERSION = '1.0.0';
+
+export const YEAR_MS = 365.25 * 86400e3; // Julian year
+export const LY_M = 9460730472580800; // metres in a light year (IAU)
+export const G = 9.80665 * (365.25 * 86400) ** 2 / LY_M; // 1 g in ly/yr^2, about 1.0323
+const MARS_YEAR_DAYS = 686.9796;
+
+// The InterImm story runs 70,491 days (about 193 years) ahead of the real calendar:
+// 2026-10-03 on Earth is 2219-10-03 in the story, the first year of terraforming phase 1.
+export const STORY_OFFSET_DAYS = 70491;
+const OFFSET_MS = STORY_OFFSET_DAYS * 86400e3;
+const ms = (d) => (d instanceof Date ? d.getTime() : Number(d));
+
+export const storyFromReal = (date) => new Date(ms(date) + OFFSET_MS);
+export const realFromStory = (date) => new Date(ms(date) - OFFSET_MS);
+
+// Nearby stars. Distances from the Sun in light years, rounded from SIMBAD/Gaia values; the Earth and Mars
+// are both within 0.00003 ly of the Sun, so the same numbers hold from either planet.
+export const STARS = [
+ { id: 'proxima', name: { en: 'Proxima Centauri', zh: '比邻星' }, distance: 4.2465, type: 'M5.5V', planets: true },
+ { id: 'alpha-centauri', name: { en: 'Alpha Centauri A/B', zh: '南门二(半人马座α)' }, distance: 4.37, type: 'G2V+K1V' },
+ { id: 'barnard', name: { en: "Barnard's Star", zh: '巴纳德星' }, distance: 5.96, type: 'M4V', planets: true },
+ { id: 'wolf-359', name: { en: 'Wolf 359', zh: '沃尔夫359' }, distance: 7.86, type: 'M6V' },
+ { id: 'lalande-21185', name: { en: 'Lalande 21185', zh: '拉兰德21185' }, distance: 8.31, type: 'M2V', planets: true },
+ { id: 'sirius', name: { en: 'Sirius', zh: '天狼星' }, distance: 8.60, type: 'A1V+DA2' },
+ { id: 'epsilon-eridani', name: { en: 'Epsilon Eridani', zh: '天苑四(波江座ε)' }, distance: 10.47, type: 'K2V', planets: true },
+ { id: 'procyon', name: { en: 'Procyon', zh: '南河三' }, distance: 11.46, type: 'F5IV+DQZ' },
+ { id: 'tau-ceti', name: { en: 'Tau Ceti', zh: '天仓五(鲸鱼座τ)' }, distance: 11.91, type: 'G8V', planets: true },
+ { id: 'teegarden', name: { en: "Teegarden's Star", zh: '蒂加登星' }, distance: 12.50, type: 'M7V', planets: true },
+ { id: 'altair', name: { en: 'Altair', zh: '牛郎星(河鼓二)' }, distance: 16.73, type: 'A7V' },
+ { id: 'vega', name: { en: 'Vega', zh: '织女星' }, distance: 25.04, type: 'A0V' },
+ { id: 'trappist-1', name: { en: 'TRAPPIST-1', zh: 'TRAPPIST-1' }, distance: 40.66, type: 'M8V', planets: true },
+ { id: 'betelgeuse', name: { en: 'Betelgeuse', zh: '参宿四' }, distance: 548, type: 'M1-2Ia', note: 'distance uncertain, 500-650 ly' },
+];
+
+export function star(idOrDistance) {
+ if (typeof idOrDistance === 'number') return { id: 'custom', name: { en: `${idOrDistance} ly`, zh: `${idOrDistance} 光年` }, distance: idOrDistance };
+ const s = STARS.find((x) => x.id === String(idOrDistance).toLowerCase());
+ if (!s) throw new Error(`Unknown star "${idOrDistance}". Known: ${STARS.map((x) => x.id).join(', ')}`);
+ return s;
+}
+
+// ---------------------------------------------------------------- messages at light speed
+
+// A message sent at `sent` reaches a star `distance` ly away one light-travel time later; an answer written
+// `replyAfter` years after it arrives gets back one light-travel time after that.
+export function message(sent, distance, { replyAfter = 0 } = {}) {
+ const t0 = ms(sent);
+ const arrives = t0 + distance * YEAR_MS;
+ const reply = arrives + replyAfter * YEAR_MS + distance * YEAR_MS;
+ return { sent: new Date(t0), arrives: new Date(arrives), earliestReply: new Date(reply), oneWayYears: distance, roundTripYears: 2 * distance + replyAfter };
+}
+
+// How far a message sent at `sent` has travelled by `now`, in ly and as a fraction of the way (0..1).
+export function messageProgress(sent, distance, now = Date.now()) {
+ const travelled = Math.max(0, (ms(now) - ms(sent)) / YEAR_MS);
+ return { travelled: Math.min(travelled, distance), fraction: Math.min(1, travelled / distance), arrived: travelled >= distance };
+}
+
+// Light received from a star at `received` left it one light-travel time earlier.
+export const emitted = (received, distance) => new Date(ms(received) - distance * YEAR_MS);
+
+// ---------------------------------------------------------------- voyages
+
+// A voyage: accelerate at `accel` (in g, felt aboard) up to `cruise` (fraction of c), coast, then brake the
+// same way if `decelerate`. With no cruise cap the ship accelerates to the midpoint and flips (or, without
+// braking, all the way). With accel 0 or null the ship is put at cruise speed instantly (pure coast).
+// Special relativity for constant proper acceleration (hyperbolic motion); times in the departure frame
+// (the Sun/Mars frame) are `earthYears`, times aboard are `shipYears`.
+export function voyage({ distance, accel = 1, cruise = null, decelerate = true }) {
+ if (!(distance > 0)) throw new Error('distance must be a positive number of light years');
+ if (cruise !== null && !(cruise > 0 && cruise < 1)) throw new Error('cruise must be between 0 and 1 (a fraction of light speed), or null');
+ const burns = decelerate ? 2 : 1;
+ const a = accel > 0 ? accel * G : 0;
+ if (!a && cruise === null) throw new Error('give a cruise speed when there is no acceleration');
+
+ let phi = cruise === null ? Infinity : Math.atanh(cruise); // rapidity at cruise
+ let burnDist = a ? (Math.cosh(phi) - 1) / a : 0;
+ if (a && burns * burnDist >= distance) { // never reaches cruise: accelerate to the midpoint (or the end) and flip
+ phi = Math.acosh(1 + (a * distance) / burns);
+ burnDist = distance / burns;
+ }
+ const beta = Math.tanh(phi), gamma = Math.cosh(phi);
+ const burnEarth = a ? Math.sinh(phi) / a : 0;
+ const burnShip = a ? phi / a : 0;
+ const coastDist = distance - burns * burnDist;
+ const coastEarth = coastDist / beta, coastShip = coastEarth / gamma;
+ const earthYears = burns * burnEarth + coastEarth;
+ const shipYears = burns * burnShip + coastShip;
+ return {
+ distance, accel: accel || 0, cruise, decelerate,
+ peakBeta: beta, peakGamma: gamma, rapidity: phi,
+ earthYears, shipYears, marsYears: earthYears * 365.25 / MARS_YEAR_DAYS,
+ timeSavedYears: earthYears - shipYears,
+ // Ideal photon rocket (exhaust at c): initial / final mass. A beamed sail carries no propellant.
+ photonRocketMassRatio: Math.exp(burns * phi),
+ phases: [
+ { phase: 'accelerate', earthYears: burnEarth, shipYears: burnShip, distance: burnDist },
+ { phase: 'coast', earthYears: coastEarth, shipYears: coastShip, distance: coastDist },
+ ...(decelerate ? [{ phase: 'decelerate', earthYears: burnEarth, shipYears: burnShip, distance: burnDist }] : []),
+ ],
+ };
+}
+
+// Where the ship is `t` Earth-frame years after departure: distance covered, speed, and time aboard.
+export function voyageAt(v, t) {
+ const a = v.accel * G;
+ const [acc, coast] = v.phases;
+ const T = v.earthYears;
+ if (t <= 0) return { t: 0, distance: 0, beta: 0, shipYears: 0, arrived: false };
+ if (t >= T) return { t, distance: v.distance, beta: v.decelerate ? 0 : v.peakBeta, shipYears: v.shipYears + (v.decelerate ? t - T : (t - T) / v.peakGamma), arrived: true };
+ const hyper = (s) => ({ x: (Math.sqrt(1 + (a * s) ** 2) - 1) / a, beta: (a * s) / Math.sqrt(1 + (a * s) ** 2), tau: Math.asinh(a * s) / a });
+ if (a && t < acc.earthYears) { const h = hyper(t); return { t, distance: h.x, beta: h.beta, shipYears: h.tau, arrived: false }; }
+ const tc = t - acc.earthYears;
+ if (tc < coast.earthYears) {
+ return { t, distance: acc.distance + tc * v.peakBeta, beta: v.peakBeta, shipYears: acc.shipYears + tc / v.peakGamma, arrived: false };
+ }
+ const h = hyper(T - t); // braking mirrors the first burn
+ return { t, distance: v.distance - h.x, beta: h.beta, shipYears: v.shipYears - h.tau, arrived: false };
+}
+
+// Ready-made ship designs for the tools. Speeds and accelerations are illustrative, in the range of published studies.
+export const PROFILES = {
+ sail: { name: { en: 'Light-sail probe (flyby)', zh: '光帆探测器(飞掠)' }, accel: 1000, cruise: 0.2, decelerate: false,
+ note: { en: 'Gram-scale probe pushed by a laser array, like Breakthrough Starshot. No braking.', zh: '激光阵列推动的克级探测器,类似“突破摄星”。不减速。' } },
+ ark: { name: { en: 'Fusion ark', zh: '聚变方舟' }, accel: 0.05, cruise: 0.1, decelerate: true,
+ note: { en: 'A crewed fusion ship at a tenth of light speed, braking at the far end.', zh: '载人聚变飞船,以十分之一光速巡航,到达时减速。' } },
+ fast: { name: { en: 'Beamed sail, crewed', zh: '载人光帆' }, accel: 0.3, cruise: 0.3, decelerate: true,
+ note: { en: 'A crewed sail pushed out from the solar system and braked by a magnetic sail.', zh: '从太阳系推出、靠磁帆减速的载人光帆。' } },
+ torch: { name: { en: 'Torch ship at 1 g', zh: '1 g 火炬船' }, accel: 1, cruise: null, decelerate: true,
+ note: { en: 'Constant 1 g all the way, flipping at the midpoint. Needs an antimatter-class engine.', zh: '全程 1 g 加速,中点掉头减速,需要反物质级的发动机。' } },
+};
+
+export function profileVoyage(profile, distance) {
+ const p = PROFILES[profile];
+ if (!p) throw new Error(`Unknown profile "${profile}". Known: ${Object.keys(PROFILES).join(', ')}`);
+ return voyage({ distance, accel: p.accel, cruise: p.cruise, decelerate: p.decelerate });
+}
+
+// Dates for a voyage leaving at `depart`: arrival in the departure frame, and the first news of arrival back home.
+export function voyageDates(v, depart) {
+ const d = ms(depart);
+ const arrives = d + v.earthYears * YEAR_MS;
+ return { depart: new Date(d), arrives: new Date(arrives), newsHome: new Date(arrives + v.distance * YEAR_MS) };
+}
+```
diff --git a/llms.txt b/llms.txt
index 9208c89..feb57bb 100644
--- a/llms.txt
+++ b/llms.txt
@@ -4,13 +4,15 @@
How to get Mars time, best option first:
-1. If you can use MCP tools: add the server `npx -y github:InterImm/mars-clock#gh-pages` (stdio). Tools: `get_mars_time` (now or any instant, any longitude), `interimm_to_earth`, `mission_sol`, `sol_to_earth`, `mars_year_dates`.
+1. If you can use MCP tools: add the server `npx -y github:InterImm/mars-clock#gh-pages` (stdio). Tools: `get_mars_time` (now or any instant, any longitude), `interimm_to_earth`, `mission_sol`, `sol_to_earth`, `mars_year_dates`, `light_message`, `interstellar_voyage`.
2. If you can run a shell with Node 18+: `npx -y github:InterImm/mars-clock#gh-pages now --longitude 137.44` prints JSON.
3. If you can run JavaScript: `import { marsTime, interimmTime } from 'https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/marstime.js'`.
4. If you can only fetch URLs: read the precomputed daily table for the year and interpolate (MSD grows by 0.973244 sol per Earth day; MTC by 24 Mars hours per sol). Or compute it yourself from the formulas in llms-full.txt.
Conventions: longitudes are degrees EAST of Airy-0 (west longitudes are negative or 360 minus). MTC, LMST and LTST are in Mars hours (1/24 sol). The InterImm clock uses Earth-length hours, so a sol reads 00:00:00 to 23:59:59 then +00:00 to +39:35. InterImm dates are written year-MM-DD with two-digit month and day (31-01-03); Year 1 began 1970-04-28. Mars Years use Clancy numbering (MY 1 began 1955-04-11; MY 39 began 2026-09-30).
+Light-year time: `lib/lightyear.js` (same CDN path) and MCP tools `light_message` (when a message to a nearby star arrives, earliest reply) and `interstellar_voyage` (relativistic trip: years on Mars vs aboard). Distances in light years, c = 1 ly/yr. The InterImm story calendar is real time + 70,491 days (2026-10-03 = 2219-10-03).
+
## Docs
- [llms-full.txt](https://interimm.org/mars-clock/llms-full.txt): every function, field and formula in one file
@@ -25,6 +27,7 @@ Conventions: longitudes are degrees EAST of Airy-0 (west longitudes are negative
- [Mars Year start dates](https://interimm.org/mars-clock/api/v1/mars-years.json)
- [InterImm calendar years](https://interimm.org/mars-clock/api/v1/interimm-years.json)
- [Missions](https://interimm.org/mars-clock/api/v1/missions.json)
+- [Nearby stars and trip times](https://interimm.org/mars-clock/api/v1/stars.json)
## Code
@@ -33,5 +36,6 @@ Conventions: longitudes are degrees EAST of Airy-0 (west longitudes are negative
## Optional
+- [Light Years page](https://interimm.org/mars-clock/en/lightyear/): letters to stars, voyage clock
- [Embeddable widget](https://interimm.org/mars-clock/en/#embed): `` or an iframe
- [Mars24 algorithm (NASA GISS)](https://www.giss.nasa.gov/tools/mars24/help/algorithm.html)
diff --git a/mcp/server.mjs b/mcp/server.mjs
index 15bc8e1..59dab7c 100755
--- a/mcp/server.mjs
+++ b/mcp/server.mjs
@@ -8,6 +8,7 @@
// It also works as a one-shot command for agents with a shell:
// npx -y github:InterImm/mars-clock#gh-pages now [--longitude 137.44] [--zone 5] [--utc 2026-10-03T10:00:00Z]
import * as mt from '../lib/marstime.js';
+import * as ly from '../lib/lightyear.js';
const SERVER = { name: 'mars-time', title: 'InterImm Mars time', version: mt.VERSION };
@@ -103,6 +104,38 @@ function marsYearDates({ marsYear }) {
return out;
}
+// Instants for the light-year tools may be real (utc) or in the InterImm story calendar (story, real + 70,491 days).
+const instant = ({ utc, story }) => (story ? Date.parse(story) - ly.STORY_OFFSET_DAYS * 86400e3 : parseUtc(utc));
+const both = (d) => ({ utc: d.toISOString(), story: ly.storyFromReal(d).toISOString() });
+const target = ({ star, distance_ly }) => {
+ if (distance_ly !== undefined) return ly.star(Number(distance_ly));
+ return ly.star(star ?? 'epsilon-eridani');
+};
+
+function lightMessage(args) {
+ const s = target(args);
+ const sent = instant(args);
+ if (!Number.isFinite(sent)) throw new Error('Could not read the date; use ISO 8601');
+ const m = ly.message(sent, s.distance, { replyAfter: args.reply_after_years ?? 0 });
+ return { star: s.name.en, distanceLy: s.distance, sent: both(m.sent), arrives: both(m.arrives), earliestReply: both(m.earliestReply),
+ roundTripYears: m.roundTripYears, note: 'Light covers one light year per Julian year. Story dates are real dates + 70,491 days (InterImm, 2026-10-03 = 2219-10-03).' };
+}
+
+function interstellarVoyage(args) {
+ const s = target(args);
+ const v = args.profile ? ly.profileVoyage(args.profile, s.distance)
+ : ly.voyage({ distance: s.distance, accel: args.accel_g ?? 1, cruise: args.cruise_c ?? null, decelerate: args.decelerate ?? true });
+ const out = { star: s.name.en, distanceLy: s.distance, ...roundAll({
+ earthYears: v.earthYears, shipYears: v.shipYears, marsYears: v.marsYears, peakSpeedC: v.peakBeta, peakGamma: v.peakGamma,
+ photonRocketMassRatio: v.photonRocketMassRatio, phases: v.phases }) };
+ if (args.depart_utc || args.depart_story) {
+ const d = ly.voyageDates(v, instant({ utc: args.depart_utc, story: args.depart_story }));
+ out.dates = { depart: both(d.depart), arrives: both(d.arrives), newsOfArrivalHome: both(d.newsHome) };
+ }
+ out.note = 'Special relativity, constant proper acceleration (felt aboard). earthYears are in the Sun/Mars frame, shipYears on the ship clock.';
+ return out;
+}
+
const TOOLS = [
{
name: 'get_mars_time',
@@ -162,6 +195,41 @@ const TOOLS = [
inputSchema: { type: 'object', required: ['marsYear'], properties: { marsYear: { type: 'integer', minimum: 1, maximum: 200 } } },
run: marsYearDates,
},
+ {
+ name: 'light_message',
+ title: 'Message to a star at light speed',
+ description: `When a radio or laser message sent now (or at a given date) reaches a nearby star, and the earliest reply. Stars: ${ly.STARS.map((x) => x.id).join(', ')}.`,
+ inputSchema: {
+ type: 'object',
+ properties: {
+ star: { type: 'string', enum: ly.STARS.map((x) => x.id), description: 'Default epsilon-eridani' },
+ distance_ly: { type: 'number', description: 'Use instead of star for any distance in light years' },
+ utc: { type: 'string', description: 'Real send date, ISO 8601. Omit for now.' },
+ story: { type: 'string', description: 'Send date in the InterImm story calendar (e.g. 2219-10-05), instead of utc' },
+ reply_after_years: { type: 'number', description: 'Years the other side takes to answer. Default 0.' },
+ },
+ },
+ run: lightMessage,
+ },
+ {
+ name: 'interstellar_voyage',
+ title: 'Relativistic trip to a star: Mars time vs ship time',
+ description: `Trip time to a star in the departure frame and aboard, peak speed, and dates. Profiles: ${Object.entries(ly.PROFILES).map(([k, p]) => `${k} (${p.name.en})`).join(', ')}; or set accel_g, cruise_c and decelerate.`,
+ inputSchema: {
+ type: 'object',
+ properties: {
+ star: { type: 'string', enum: ly.STARS.map((x) => x.id), description: 'Default epsilon-eridani' },
+ distance_ly: { type: 'number' },
+ profile: { type: 'string', enum: Object.keys(ly.PROFILES) },
+ accel_g: { type: 'number', description: 'Proper acceleration in g (default 1); 0 = start at cruise speed' },
+ cruise_c: { type: 'number', description: 'Cruise speed as a fraction of c (0-1); omit to accelerate to the midpoint' },
+ decelerate: { type: 'boolean', description: 'Brake at the destination (default true)' },
+ depart_utc: { type: 'string' },
+ depart_story: { type: 'string', description: 'Departure in the InterImm story calendar, e.g. 2230-01-01' },
+ },
+ },
+ run: interstellarVoyage,
+ },
];
// ---------------------------------------------------------------- one-shot CLI
@@ -192,7 +260,7 @@ function handle(req) {
protocolVersion: params.protocolVersion || '2025-06-18',
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER,
- instructions: 'Use get_mars_time for "what time is it on Mars" questions. Longitudes are degrees east. Results are computed locally with NASA\'s Mars24 algorithm.',
+ instructions: 'Use get_mars_time for "what time is it on Mars" questions. Longitudes are degrees east. Results are computed locally with NASA\'s Mars24 algorithm. For nearby stars use light_message and interstellar_voyage (special relativity; story dates = real + 70,491 days).',
} });
case 'ping':
return send({ id, result: {} });
diff --git a/mcp/server.test.mjs b/mcp/server.test.mjs
index c328039..b290889 100644
--- a/mcp/server.test.mjs
+++ b/mcp/server.test.mjs
@@ -37,7 +37,7 @@ test('MCP handshake, tool list and calls', async () => {
const list = await s.call('tools/list', {});
assert.deepEqual(list.result.tools.map((t) => t.name).sort(),
- ['get_mars_time', 'interimm_to_earth', 'mars_year_dates', 'mission_sol', 'sol_to_earth']);
+ ['get_mars_time', 'interimm_to_earth', 'interstellar_voyage', 'light_message', 'mars_year_dates', 'mission_sol', 'sol_to_earth']);
for (const t of list.result.tools) assert.equal(t.inputSchema.type, 'object');
const now = await s.call('tools/call', { name: 'get_mars_time', arguments: { utc: '2026-10-03T10:00:00Z', longitude: 137.44 } });
@@ -57,6 +57,15 @@ test('MCP handshake, tool list and calls', async () => {
const my = await s.call('tools/call', { name: 'mars_year_dates', arguments: { marsYear: 39 } });
assert.match(my.result.structuredContent.startUtc, /^2026-09-30/);
+ const msg = await s.call('tools/call', { name: 'light_message', arguments: { star: 'epsilon-eridani', story: '2219-10-05T00:00:00Z' } });
+ assert.match(msg.result.structuredContent.arrives.story, /^2230-03/);
+ assert.match(msg.result.structuredContent.sent.utc, /^2026-10-05/);
+
+ const trip = await s.call('tools/call', { name: 'interstellar_voyage', arguments: { profile: 'torch', depart_story: '2230-01-01T00:00:00Z' } });
+ const tr = trip.result.structuredContent;
+ assert.ok(Math.abs(tr.shipYears - 4.93) < 0.01 && Math.abs(tr.earthYears - 12.26) < 0.01);
+ assert.match(tr.dates.arrives.story, /^2242-0[34]/);
+
const bad = await s.call('tools/call', { name: 'mission_sol', arguments: { mission: 'nope' } });
assert.equal(bad.result.isError, true);
const unknown = await s.call('nope/nope', {});
diff --git a/package.json b/package.json
index bf7f1a4..776b02c 100644
--- a/package.json
+++ b/package.json
@@ -8,16 +8,18 @@
"exports": {
".": "./lib/marstime.js",
"./element": "./lib/mars-clock-element.js",
- "./card": "./lib/mars-clock-card.js"
+ "./card": "./lib/mars-clock-card.js",
+ "./lightyear": "./lib/lightyear.js"
},
"files": [
"lib/marstime.js",
+ "lib/lightyear.js",
"lib/mars-clock-element.js",
"lib/mars-clock-card.js",
"mcp/server.mjs"
],
"scripts": {
- "test": "node --test lib/marstime.test.mjs mcp/server.test.mjs",
+ "test": "node --test lib/marstime.test.mjs lib/lightyear.test.mjs mcp/server.test.mjs",
"build:api": "node tools/build-api.mjs"
},
"engines": {
diff --git a/tools/build-api.mjs b/tools/build-api.mjs
index a9c440e..e2c7fcd 100644
--- a/tools/build-api.mjs
+++ b/tools/build-api.mjs
@@ -2,6 +2,7 @@
// Output is deterministic (no timestamps), so CI can check the committed files are up to date.
import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import * as mt from '../lib/marstime.js';
+import * as ly from '../lib/lightyear.js';
const OUT = new URL('../api/v1/', import.meta.url);
const FIRST_YEAR = 2000, LAST_YEAR = 2050;
@@ -48,6 +49,14 @@ write('interimm-years.json', {
});
write('missions.json', { about: 'Landers and rovers with their own sol counts. Longitude is degrees east; sol 0 is the landing sol.', missions: mt.MISSIONS });
+const voyageRow = (v) => ({ earthYears: r(v.earthYears, 4), shipYears: r(v.shipYears, 4), peakBeta: r(v.peakBeta, 4) });
+write('stars.json', {
+ about: 'Nearby stars: distance from the Sun in light years (= one-way light time in years, from Earth or Mars), ' +
+ 'and trip times for each ship profile in years (earthYears in the Sun/Mars frame, shipYears aboard).',
+ storyOffsetDays: ly.STORY_OFFSET_DAYS,
+ profiles: Object.fromEntries(Object.entries(ly.PROFILES).map(([k, p]) => [k, { name: p.name, accel: p.accel, cruise: p.cruise, decelerate: p.decelerate }])),
+ stars: ly.STARS.map((s) => ({ ...s, voyages: Object.fromEntries(Object.keys(ly.PROFILES).map((k) => [k, voyageRow(ly.profileVoyage(k, s.distance))])) })),
+});
write('leap-seconds.json', { about: 'TAI-UTC in seconds from each UTC date. Source: IERS.', leapSeconds: mt.LEAP_SECONDS });
const columns = ['utc', 'msd', 'mtc', 'ls', 'marsYear', 'interimmDate', 'interimmClock', 'curiositySol', 'perseveranceSol'];
@@ -100,6 +109,8 @@ write('openapi.json', {
years: { type: 'array', items: { type: 'object', properties: { year: int, start: str, sols: int } } } } } } } } } } },
'/missions.json': { get: { operationId: 'getMissions', summary: 'Landers and rovers: landing time (UTC) and longitude (degrees east)',
responses: { 200: { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } } },
+ '/stars.json': { get: { operationId: 'getStars', summary: 'Nearby stars: distance in light years and relativistic trip times per ship profile',
+ responses: { 200: { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } } },
'/leap-seconds.json': { get: { operationId: 'getLeapSeconds', summary: 'TAI-UTC table used for the conversion',
responses: { 200: { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } } },
},
@@ -116,7 +127,9 @@ write('index.json', {
interimmYears: 'interimm-years.json',
missions: 'missions.json',
leapSeconds: 'leap-seconds.json',
+ stars: 'stars.json: nearby stars, light time and trip times',
},
+ lightyear: 'https://cdn.jsdelivr.net/gh/InterImm/mars-clock@gh-pages/lib/lightyear.js',
openapi: 'openapi.json',
llms: 'https://interimm.org/mars-clock/llms.txt',
mcp: 'npx -y github:InterImm/mars-clock#gh-pages',
@@ -124,7 +137,9 @@ write('index.json', {
// llms-full.txt = hand-written guide + the library's own source, so a model sees exactly what runs.
const head = readFileSync(new URL('./llms-full.head.md', import.meta.url), 'utf8');
const lib = readFileSync(new URL('../lib/marstime.js', import.meta.url), 'utf8');
+const lyLib = readFileSync(new URL('../lib/lightyear.js', import.meta.url), 'utf8');
writeFileSync(new URL('../llms-full.txt', import.meta.url),
- `${head}\n## The library source (lib/marstime.js)\n\n\`\`\`js\n${lib}\`\`\`\n`);
+ `${head}\n## The library source (lib/marstime.js)\n\n\`\`\`js\n${lib}\`\`\`\n` +
+ `\n## Light-year time (lib/lightyear.js)\n\n\`\`\`js\n${lyLib}\`\`\`\n`);
console.log(`api/v1 written: ${LAST_YEAR - FIRST_YEAR + 1} daily files`);