# Driving Zodiac Sky

Zodiac Sky is built to be driven by an agent — a browser-driving LM (Claude in Chrome),
the user's own assistant, or anything that can either run JS on the page or construct a URL.
The app does all the astronomy locally, so a driver spends **no tokens inside the app**: it
just reads the current state and writes a new one.

There are two ways to drive it, and you never need to memorize a hash for either:

1. **JS API** — run `window.zodiac.*` on the page. The app updates the URL for you.
2. **URL construction** — build a `#…` hash from the schema below and navigate to it.

`window.zodiac.state()` returns the *exact shape* that `apply()`/`goto()` accept, so the
simplest workflow is: read `state()`, change the fields you care about, write it back.

---

## The state object

`state()` returns (and `apply()` accepts a partial of):

| field        | type                          | meaning |
|--------------|-------------------------------|---------|
| `time`       | ISO string (or `Date`)        | the moment shown |
| `view.mode`  | `"celestial"` \| `"local"`    | star-locked (pole up) vs horizon-locked (zenith up, level horizon) |
| `view.lon`   | number, deg                   | celestial: ecliptic longitude looked at. When sun-locked, `0` centers the Sun. |
| `view.lat`   | number, deg                   | celestial: ecliptic latitude looked at (0 = on the ecliptic) |
| `view.az`    | number, deg                   | local mode: azimuth looked toward (0 = N, 90 = E) |
| `view.alt`   | number, deg                   | local mode: altitude looked toward |
| `view.scale` | number (~280–6000)            | zoom; bigger = more zoomed in. Default 900. |
| `place`      | `{lat, lon, name}`            | the observing location (sets the horizon & day/night) |
| `horizonOn`  | boolean                       | show horizon line + day/night sky tint + below-horizon dimming |
| `sunLock`    | boolean                       | celestial only: pin the Sun, let everything drift past it |
| `natal`      | birth-seed object \| `null`   | the cast natal chart (see below) |

The **natal seed** (what you pass, and what `state().natal` returns):

```js
{ year, month, day, hour, minute,   // birth date & LOCAL clock time
  lat, lon,                         // birthplace
  tz,                               // UTC offset in hours for that date (e.g. -5, 5.5)
  place }                           // display label (string)
```

---

## `window.zodiac` API

```js
zodiac.state()            // -> snapshot (the table above), serializable
zodiac.apply(partial)     // synchronous. Only the fields you pass change. Returns nothing.
zodiac.goto(partial)      // async, ergonomic. Like apply(), but `place` (and natal `place`)
                          //   may be a NAME STRING — it's geocoded for you. Resolves to state().
zodiac.geocode(query)     // async -> {lat, lon, name}. OpenStreetMap Nominatim.
```

**Partial semantics** (apply & goto):
- Pass only what changes; everything else is left alone.
- `natal` is applied *first*, so you can cast a chart **and** jump to a transit date in one
  call (`{natal:{…}, time:"2030-01-01"}` lands on 2030 with the natal frame, not at birth).
- `view.mode:"local"` is ignored while a natal chart is active (natal lives in the ecliptic
  frame). `sunLock` is ignored in local mode and released automatically when you enter it.
- Setting `place` turns the horizon on.

**Prefer `goto()` when you have a place name; `apply()` when you already have coordinates.**

```js
// "Show me the sky over Swampscott at the Aug 2026 total solar eclipse, Sun centered."
await zodiac.goto({
  time: "2026-08-12T18:00:00Z",
  place: "Swampscott, MA",
  sunLock: true,
  view: { lon: 0, scale: 1500 },   // lon 0 centers the pinned Sun
});

// "Cast my natal chart and show me my transits on my 40th birthday."
await zodiac.goto({
  natal: { year:1985, month:6, day:1, hour:14, minute:20, tz:-7, place:"Phoenix, AZ" },
  time: "2025-06-01T12:00:00Z",
});
// (natal place is geocoded; tz you still supply, since it depends on the birth date's DST.)

// Read, tweak, write:
const s = zodiac.state();
zodiac.apply({ time: new Date(Date.parse(s.time) + 30*86400e3).toISOString() }); // +30 days
```

---

## URL hash schema

The full session lives in the URL hash, so any state is a shareable/bookmarkable link, and a
driver that navigates (rather than runs JS) can construct one directly. Keys:

| key   | value                                              | default        |
|-------|----------------------------------------------------|----------------|
| `t`   | ISO minute + `Z`, e.g. `2026-08-12T18:00Z`         | now            |
| `m`   | `celestial` \| `local`                             | `celestial`    |
| `v`   | `lon,lat,az,alt,scale` (5 numbers, comma-joined)   | `70,0,180,25,900` |
| `pll` | `lat,lon` of observing place                       | Swampscott     |
| `pn`  | place display name (URL-encoded)                   | `Swampscott`   |
| `hz`  | `1` \| `0` — horizon on/off                        | `1`            |
| `sun` | `1` \| `0` — sun-lock                              | `0`            |
| `nt`  | natal birth datetime `YYYY-MM-DDTHH:MM` (no tz)    | *(none)*       |
| `nll` | natal `lat,lon,tz`                                 | *(none)*       |
| `np`  | natal place name (URL-encoded)                     | *(none)*       |

Notes:
- Unknown/missing keys fall back to defaults; a malformed hash is ignored (logs a warning).
- The URL uses **coordinates, not place names**, so a shared link loads deterministically
  with no network call. Use `geocode()` once to get coordinates when building a link by hand.
- `t` is UTC. The natal `nt` is the **local clock time** at birth; `tz` (in `nll`) converts it.

### Worked URL examples

Live sky right now over Swampscott (essentially the default — empty hash also works):
```
#m=celestial&v=70,0,180,25,900&pll=42.4709,-70.9178&pn=Swampscott&hz=1&sun=0
```

The Aug 12 2026 total solar eclipse, Sun-locked and centered, from Swampscott:
```
#t=2026-08-12T18:00Z&m=celestial&v=0,0,180,25,1500&pll=42.4709,-70.9178&pn=Swampscott&hz=1&sun=1
```

Horizon view, looking due south, this evening from Swampscott:
```
#t=2026-05-31T01:00Z&m=local&v=70,0,180,20,900&pll=42.4709,-70.9178&pn=Swampscott&hz=1&sun=0
```

A natal chart (Phoenix, 1 Jun 1985 14:20 MST = tz −7), viewed at birth:
```
#t=1985-06-01T21:20Z&m=celestial&v=120,0,180,25,900&pll=33.4484,-112.074&pn=Phoenix&hz=1&sun=0&nt=1985-06-01T14:20&nll=33.4484,-112.074,-7&np=Phoenix
```

---

## Driving from a browser-automation tool

If you can run JS on the page (e.g. an `evaluate`/`javascript` tool), call `window.zodiac.goto({…})`
and the URL + render update together — the human watching the page sees it move. If you can
only navigate, build the hash above and load `…/index.html#<hash>`. Either way, call
`zodiac.state()` afterward to confirm where you landed.

The app is served at `http://homebase.local:8731/index.html` on the LAN (dev), and at
`https://zodiac-sky.pages.dev/` (production).
