For the builder implementing this as vanilla JS/WebGL1/Canvas2D in a single HTML file, no libraries. Two independent deliverables: (1) the King Wen hexagram structure and its bijection onto the Bayer-8 matrix already in the canon, (2) real, cite-able astronomical computation producing a reproducible 32-bit seed from a Date. Studied first: /home/claude/mn/public/preview/hexagram-grid.html and /home/claude/mn/src/canon/dither.js.
Part 1 — King Wen structure and the Bayer bijection
1.1 Trigram binary encoding (Fu Xi / Shao Yong values)
Bottom line = bit 0, yang = 1, yin = 0 (same convention hexagram-grid.html already uses). The eight trigrams:
| Trigram | Lines (bottom→top) | Value |
|---|---|---|
| Qian ☰ (Heaven) | yang yang yang | 7 |
| Dui ☱ (Lake) | yang yang yin | 3 |
| Li ☲ (Fire) | yang yin yang | 5 |
| Zhen ☳ (Thunder) | yang yin yin | 1 |
| Xun ☴ (Wind) | yin yang yang | 6 |
| Kan ☵ (Water) | yin yang yin | 2 |
| Gen ☶ (Mountain) | yin yin yang | 4 |
| Kun ☷ (Earth) | yin yin yin | 0 |
A hexagram's 6-bit value = lower_trigram | (upper_trigram << 3). This is exactly what hexagram-grid.html already builds (value = ((7-row)<<3)|col), and it's why the file's own comment can say Fu Xi order is "plain binary 0..63" — the natural binary count 0→63, read as six lines bottom-up, already is a complete, non-arbitrary ordering of all 64 hexagrams. No table is needed for Fu Xi/Shao Yong order; it falls out of the encoding.
1.2 King Wen order — data, not arithmetic
King Wen order is not derivable from the binary count — it's a received sequence (traditionally attributed to King Wen of Zhou and the Duke of Zhou, ~11th c. BCE; the numbering here follows the standard Wilhelm–Baynes edition, the one nearly every English reference uses). Give the builder the trigram pair per number and let code compute the 6-bit value — hand-computing 64 decimal values invites transcription error; the pair table is the stable, checkable artifact:
// King Wen order, 1-indexed. [upper, lower] trigram letters:
// Q=Qian D=Dui L=Li Z=Zhen X=Xun K=Kan G=Gen U=Kun
const KING_WEN = [
null, // pad so index === hexagram number
['Q','Q'],['U','U'],['K','Z'],['G','K'],['K','Q'],['Q','K'],['U','K'],['K','U'],
['X','Q'],['Q','D'],['U','Q'],['Q','U'],['Q','L'],['L','Q'],['U','G'],['Z','U'],
['D','Z'],['G','X'],['U','D'],['X','U'],['L','Z'],['G','L'],['G','U'],['U','Z'],
['Q','Z'],['G','Q'],['G','Z'],['D','X'],['K','K'],['L','L'],['D','G'],['Z','X'],
['Q','G'],['Z','Q'],['L','U'],['U','L'],['X','L'],['L','D'],['K','G'],['Z','K'],
['G','D'],['X','Z'],['D','Q'],['Q','X'],['D','U'],['U','X'],['D','K'],['K','X'],
['D','L'],['L','X'],['Z','Z'],['G','G'],['X','G'],['Z','D'],['Z','L'],['L','G'],
['X','X'],['D','D'],['X','K'],['K','D'],['X','D'],['Z','G'],['K','L'],['L','K'],
];
const TRIGRAM_VALUE = { Q:7, D:3, L:5, Z:1, X:6, K:2, G:4, U:0 };
const kingWenValue = n => {
const [up, lo] = KING_WEN[n];
return TRIGRAM_VALUE[lo] | (TRIGRAM_VALUE[up] << 3);
};
Verify this table against a printed Wilhelm–Baynes source before treating it as ground truth — it's reproduced from memory of a very standard reference, not derived, and a single transposed pair breaks the whole sequence silently (it'll still be a permutation of 64, just the wrong one). A cheap runtime check: kingWenValue(1) === 63 (all yang), kingWenValue(2) === 0 (all yin), kingWenValue(29) === kingWenValue(30's mirror)... see below.
1.3 Pairing structure
King Wen is organized as 32 pairs, adjacent (2k-1, 2k). Two pairing rules operate, and exactly one applies to each hexagram:
-
綜卦 (zōng guà) — inversion (turn the figure upside down, i.e. reverse the six lines top-to-bottom). 56 of the 64 hexagrams pair this way: hexagram n and n+1 are line-reversals of each other.
reverse6(v) = ((v&1)<<5)|((v&2)<<3)|((v&4)<<1)|((v&8)>>1)|((v&16)>>3)|((v&32)>>5). -
錯卦 (cuò guà) — opposition (invert every line, yang↔yin). The 8 hexagrams that are palindromic under inversion (they look the same upside down, so 綜卦 is the identity and useless as a pairing) instead pair by full bit-complement:
v ^ 0b111111. These 8 are: 1/2 (Qian/Kun, all-yang/all-yin), 27/28 (頤/大過), 29/30 (坎/離, doubled Kan/doubled Li), 61/62 (中孚/小過). Note all four doubled-trigram hexagrams (11,22,33,44,55,66 in binary — i.e. 0,9,18,27,36,45,54,63 as raw values — only the palindromic ones land here; the others like 51/52 (Zhen/Zhen, Gen/Gen) and 57/58 (Xun/Xun, Dui/Dui) are not palindromic under line-reversal in the King Wen adjacency and are handled as ordinary consecutive numbers, not overturned pairs, in some traditions — this is a genuine point of scholarly variation; treat it as decorative structure, not something the renderer needs to get canonically "right").
This gives a clean, checkable invariant for whichever 64 you render: for every King Wen pair (2k-1, 2k), either reverse6(v[2k-1]) === v[2k] or v[2k-1] ^ v[2k] === 0b111111. Use it as a self-test on the table in §1.2, not just decoration.
1.4 The Bayer-8 bijection — recommendation
Map hexagram → grid cell by (row = upper trigram value, col = lower trigram value), then read that cell's existing Bayer-8 threshold as the hexagram's intensity/rank. No new indexing scheme — overlay directly onto the grid hexagram-grid.html already draws.
Why this is the meaningful bijection and not just a same-cardinality coincidence: look at how bayerMatrix() is actually built (/home/claude/mn/src/canon/dither.js:21) —
M1 = [[0]]
M2n = [[ 4M+0, 4M+2 ],
[ 4M+3, 4M+1 ]]
Three applications of this doubling take a 1×1 matrix to 8×8. Each doubling adds exactly one bit to the row address and one bit to the column address — the matrix is defined as a 3-level recursive quadrant refinement, i.e. 3 bits of row, 3 bits of column, built compositionally. A hexagram is defined the same way: 3 bits of upper trigram, 3 bits of lower trigram, and King Wen's own trigram theory already treats a hexagram as two trigrams stacked, each trigram itself built line-by-line (a hexagram is two nested 3-bit refinements). The two structures aren't merely both size-64 — they're both "two independent 3-bit addresses assembled by recursive halving," which is the actual reason Bayer-8 and the trigram square are isomorphic as data shapes, not just as counts.
So: row = upperTrigramValue(hex), col = lowerTrigramValue(hex), then bayerThreshold(8)(col, row) gives that hexagram's dither rank in [0,1) — usable directly as a per-hexagram brightness, reveal-order, or halftone-cell assignment, with zero extra lookup structure. It also means: the traditional Shao Yong "before-heaven" 8×8 square (the one Leibniz was shown) and the Bayer-8 ordered-dither matrix are the same object wearing two labels — worth stating in the piece itself, since it's the kind of thing CANON-SEED rule 6 ("hidden depth rewards inspection") wants: someone who checks the math finds it's not decoration.
Alternative rejected: mapping King-Wen-sequence-position n (1–64) directly to raster-scan Bayer cell n-1. Simpler, but throws away the actual algebraic correspondence — it would work for any two size-64 sets and teaches the viewer nothing true about either structure. Don't use it.
Part 2 — Real ephemeris, no library
All of this needs a Julian Day helper first (Meeus, Astronomical Algorithms, ch. 7 — the standard Gregorian-calendar JD formula):
function toJulianDay(date) {
const Y0 = date.getUTCFullYear(), M0 = date.getUTCMonth() + 1;
const d = date.getUTCDate() + (date.getUTCHours() + date.getUTCMinutes()/60
+ date.getUTCSeconds()/3600) / 24;
let Y = Y0, M = M0;
if (M <= 2) { Y -= 1; M += 12; }
const A = Math.floor(Y / 100);
const B = 2 - A + Math.floor(A / 4);
return Math.floor(365.25 * (Y + 4716)) + Math.floor(30.6001 * (M + 1)) + d + B - 1524.5;
}
function fromJulianDay(jd) { // inverse — JD 2440587.5 == Unix epoch
return new Date((jd - 2440587.5) * 86400000);
}
2.1 Moon phase
Two independent methods — use the second, it's simpler and more directly astronomical:
(a) Conway's algorithm (John H. Conway & Richard Guy, The Book of Numbers, 1996) — calendar arithmetic, no JD needed, accurate to the day:
function moonAgeConway(date) {
const y = date.getUTCFullYear(), m = date.getUTCMonth() + 1, d = date.getUTCDate();
let r = y % 100;
r %= 19;
if (r > 9) r -= 19;
r = ((r * 11) % 30) + m + d;
if (m < 3) r += 2;
r -= (y < 2000) ? 4 : 8.3;
r = Math.floor(r + 0.5) % 30;
return r < 0 ? r + 30 : r; // 0 = new moon, ~14.8 = full
}
(b) Synodic-period method (a known new moon epoch + the mean synodic month — this is the "real" astronomical quantity, the one Conway's arithmetic is a shortcut for):
const SYNODIC_MONTH = 29.530588861; // mean, days (IAU)
const KNOWN_NEW_MOON_JD = 2451549.5; // 2000-01-06 18:14 UTC
function moonPhase01(date) { // 0 = new, 0.5 = full, →1 = new again
const days = toJulianDay(date) - KNOWN_NEW_MOON_JD;
const cycles = days / SYNODIC_MONTH;
return cycles - Math.floor(cycles);
}
Recommend (b): one constant, one epoch, directly legible as "phase = position in the current lunation," and it doesn't carry Conway's calendar-era fudge terms (-4/-8.3) which are a giveaway that it's curve-fit rather than derived. Both are accurate to well under a day near J2000 and drift slowly (the true synodic month varies ±0.5 day across a cycle due to orbital eccentricity) — fine for "accurate to the day," not for eclipse-grade work.
2.2 Day of year
function dayOfYear(date) {
const start = Date.UTC(date.getUTCFullYear(), 0, 1);
const today = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
return Math.floor((today - start) / 86400000) + 1; // 1..366
}
2.3 Sunrise / sunset (needed for planetary hours)
The NOAA sunrise equation, derived from Meeus — low-precision solar position, good to about a minute of time, which is what planetary-hour granularity needs:
const D2R = Math.PI / 180, R2D = 180 / Math.PI;
function sunTimes(date, lat, lon) {
const JD = toJulianDay(date);
const n = Math.round(JD - 2451545.0 + 0.0008);
const Jstar = n - lon / 360;
const M = (357.5291 + 0.98560028 * Jstar) % 360;
const Mr = M * D2R;
const C = 1.9148*Math.sin(Mr) + 0.0200*Math.sin(2*Mr) + 0.0003*Math.sin(3*Mr);
const lambda = (M + C + 180 + 102.9372) % 360;
const lambdaR = lambda * D2R;
const Jtransit = 2451545.0 + Jstar + 0.0053*Math.sin(Mr) - 0.0069*Math.sin(2*lambdaR);
const decl = Math.asin(Math.sin(lambdaR) * Math.sin(23.4397 * D2R));
const latR = lat * D2R;
const cosH = (Math.sin(-0.83*D2R) - Math.sin(latR)*Math.sin(decl))
/ (Math.cos(latR) * Math.cos(decl));
if (cosH > 1) return { sunrise: null, sunset: null, transit: fromJulianDay(Jtransit), polarNight: true };
if (cosH < -1) return { sunrise: null, sunset: null, transit: fromJulianDay(Jtransit), midnightSun: true };
const H = Math.acos(cosH) * R2D;
return {
sunrise: fromJulianDay(Jtransit - H/360),
sunset: fromJulianDay(Jtransit + H/360),
transit: fromJulianDay(Jtransit),
};
}
This will fail closed near the poles in summer/winter (|cosH| > 1, no sunrise/sunset that day) — the function returns null times and a flag rather than NaN dates; the caller (planetary hour) must handle it explicitly (fall back to civil midnight/noon boundaries, or just refuse to render planetary hour at that latitude and say so).
2.4 Planetary hour, Chaldean order
Daylight and night are each divided into 12 unequal "temporal hours." The ruling sequence across all 24 (day+night) hours repeats the seven classical planets in Chaldean order — Saturn, Jupiter, Mars, Sun, Venus, Mercury, Moon, ordered by decreasing apparent geocentric orbital period. Because 24 isn't a multiple of 7, the ruler of hour 1 advances by 3 positions each successive day, which is the actual mechanism behind the familiar weekday-planet correspondence (Sunday↔Sun, Monday↔Moon, …). No planetary glyphs — Departure doesn't carry ♄♃♂☉♀☿☽ — use names throughout, as below:
const CHALDEAN = ['Saturn','Jupiter','Mars','Sun','Venus','Mercury','Moon'];
const WEEKDAY_RULER = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn']; // getUTCDay(): 0=Sun
function planetaryHour(date, lat, lon) {
const today = sunTimes(date, lat, lon);
if (!today.sunrise) return { planet: null, reason: 'no sunrise/sunset at this latitude/date' };
let start, end, isDay;
if (date >= today.sunrise && date < today.sunset) {
start = today.sunrise; end = today.sunset; isDay = true;
} else if (date >= today.sunset) {
const tmr = sunTimes(new Date(date.getTime() + 86400000), lat, lon);
start = today.sunset; end = tmr.sunrise; isDay = false;
} else {
const yst = sunTimes(new Date(date.getTime() - 86400000), lat, lon);
start = yst.sunset; end = today.sunrise; isDay = false;
}
const hourLenMs = (end - start) / 12;
const slot = Math.min(11, Math.floor((date - start) / hourLenMs)); // 0..11
const globalHour = isDay ? slot : slot + 12; // 0..23
const dayRulerIdx = CHALDEAN.indexOf(WEEKDAY_RULER[date.getUTCDay()]);
const planet = CHALDEAN[(dayRulerIdx + globalHour) % 7];
return { planet, hourOfDay: globalHour + 1, isDay, hourLengthMin: hourLenMs / 60000 };
}
2.5 Lunar mansion index
28 mansions (Chinese 宿/xiù, Arabic manazil, Vedic nakshatra — pick the count that matches whichever tradition; 27 for nakshatra, 28 for the Chinese/Arabic systems), each spanning 360/28 ≈ 12.857° of ecliptic longitude. Needs the Moon's ecliptic longitude — a truncated low-precision series (Meeus, Astronomical Algorithms, ch. 47, six largest periodic terms only; full ELP2000 has hundreds and is out of scope for a no-library artifact):
function moonEclipticLongitude(date) {
const T = (toJulianDay(date) - 2451545.0) / 36525; // Julian centuries since J2000
const Lp = 218.3164477 + 481267.88123421*T; // mean longitude
const D = 297.8501921 + 445267.1114034*T; // mean elongation from Sun
const M = 357.5291092 + 35999.0502909*T; // Sun mean anomaly
const Mp = 134.9633964 + 477198.8675055*T; // Moon mean anomaly
const F = 93.2720950 + 483202.0175233*T; // argument of latitude
const r = D2R;
const dL = 6.288774*Math.sin(Mp*r) + 1.274027*Math.sin((2*D-Mp)*r)
+ 0.658314*Math.sin(2*D*r) + 0.213618*Math.sin(2*Mp*r)
- 0.185116*Math.sin(M*r) - 0.114332*Math.sin(2*F*r);
const lon = (Lp + dL) % 360;
return lon < 0 ? lon + 360 : lon;
}
function lunarMansionIndex(date, mansions = 28) {
return Math.floor(moonEclipticLongitude(date) / (360 / mansions)); // 0..mansions-1
}
Six terms gives roughly ±0.3° accuracy — well inside a 12.86° mansion width, so the index is reliable even though the longitude itself isn't ephemeris-grade.
Part 3 — Deterministic 32-bit seed from a Date
Combine everything above with a well-mixed, order-sensitive hash (FNV-1a — Fowler/Noll/Vo, public-domain, one multiply/one xor per step, excellent avalanche for this scale) rather than simple addition, which would let, e.g., moon-age 5 + hour 10 collide with moon-age 10 + hour 5:
function fnv1aMix(h, x) {
h ^= (x >>> 0);
h = Math.imul(h, 16777619);
return h >>> 0;
}
function deriveSeed(date, lat, lon) {
const year = date.getUTCFullYear();
const doy = dayOfYear(date);
const moonAge = Math.round(moonPhase01(date) * SYNODIC_MONTH); // 0..29
const hourInfo = planetaryHour(date, lat, lon);
const hourOfDay = hourInfo.planet ? hourInfo.hourOfDay : 0; // 0..24
const mansion = lunarMansionIndex(date); // 0..27
const hexValue = ((doy % 64) ^ (moonAge << 1) ^ hourOfDay) & 0b111111;
let h = 2166136261 >>> 0; // FNV-1a offset basis
h = fnv1aMix(h, year);
h = fnv1aMix(h, doy);
h = fnv1aMix(h, moonAge);
h = fnv1aMix(h, hourOfDay);
h = fnv1aMix(h, mansion);
h = fnv1aMix(h, hexValue);
// instrumented — every step visible, per §16 evidence-not-memory
console.table({ year, doy, moonAge, planet: hourInfo.planet, hourOfDay, mansion, hexValue,
seed: '0x' + h.toString(16).padStart(8, '0') });
return h;
}
PRNG from the seed — mulberry32 (Tommy Ettinger; public domain; 4 multiplies, passes practical randomness suites, tiny) rather than Math.random(), which is not seedable and breaks the "same seed, same output" law outright:
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// usage: const rng = mulberry32(deriveSeed(new Date(), lat, lon)); rng() → [0,1)
The whole chain — Date → JD/calendar fields → {moonAge, planet/hour, mansion, hexValue} → FNV-1a → seed → mulberry32 stream — is pure and side-effect-free apart from console.table. Same Date + same (lat,lon) always produces the same seed, satisfying the reproducibility law directly, and the instrumentation (console.table) gives the "hidden depth rewards inspection" the canon wants for free — open devtools, see every intermediate value that produced tonight's field.
What's hard, what will go wrong
-
Timezone. Every function above is written against UTC fields (
getUTCFullYearetc.) deliberately — if the builder swaps in local-time getters anywhere, the seed becomes different per-viewer-timezone for the "same" date, which breaks reproducibility silently (it'll still run, just disagree with itself across machines). Pick UTC and never mix. - Latitude/longitude for planetary hour. Without geolocation there's no real sunrise, so either (a) fix a reference location as part of the seed (e.g. an arbitrary but documented coordinate — reproducible, just not "for the viewer"), or (b) ask permission and accept that the artifact then differs by viewer location, which contradicts "same seed, same output" unless location is folded into the seed input explicitly. Decide this before writing code, not after — it's a law question, not an implementation detail.
-
Polar sunrise failure.
sunTimesreturns nulls above roughly the Arctic/Antarctic circle for parts of the year.planetaryHourabove handles it by refusing rather than producingNaN, but whatever calls it must have a real fallback (fixed reference latitude, as above, sidesteps this entirely and is the simpler choice for a deterministic artifact). - King Wen table correctness. The pair table in §1.2 is reproduced from standard memory, not derived from a primary source at hand in this environment — verify it against a real edition before treating any single hexagram's number as ground truth for content (names, judgments). The pairing invariant in §1.3 is a good automated smoke test once the table is in code.
- Precision framing. None of this is ephemeris-grade (no VSOP87, no ELP2000-82B, no nutation/aberration). It is genuinely real astronomy — Meeus's own low-order series, the standard sunrise equation, IAU's synodic month constant — just the truncated, hand-computable tier of it. Say "accurate to the day / to the minute" in any on-surface copy, not "precise" — overclaiming here is the kind of thing that gets caught by anyone who checks.
-
Astrological glyphs are a hard no, not a style preference: Departure Mono measurably lacks
♄♃♂☉♀☿☽per §7 of DESIGN.md, and CHALDEAN/WEEKDAY_RULER above are already spelled with names for exactly that reason — don't let a later pass "improve" this back to symbols. -
Colour law for this content. Hexagram lines are instrumentation (sigil), matching what
hexagram-grid.htmlalready does — solid/broken lines in ink, sigil on hover/change, never essence. Planet names, mansion index, seed hex string are all Departure labels at the opacity ladder, not colour-coded — brightness only, per §1's status rule, and specifically not essence cyan, which is reserved for the one live thing on screen (if a build gives the current moon-age dot a living pulse on the canonicalsin⁴(t·1.55)beat, that dot alone may take essence; the rest of this data is instrumentation, i.e. sigil-text#cd5860, not cyan). -
Reduced motion. Since the whole chain is deterministic and cheap to compute synchronously, a reduced-motion frame is not a special case to build separately — freeze at the canonical
t=3.0and just render the already-computed seed's static values (today's hexagram, phase, hour, mansion) with no animation loop at all. This is the easy path because the seed derivation is pure; don't let the renderer accidentally make phase-of-day a per-frameDate.now()read, which would make the reduced-motion frame also non-reproducible.