Files
hanzo.ai/components/zen/HexagramExplorer.tsx

527 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
// The 64-pattern experience. One client island, three views over one dataset:
// Sequence — the King Wen order, grouped into the eight arcs of change.
// Matrix — the 8×8 field of upper/lower trigram pairs.
// Lenses — the same patterns read through four interpretive minds.
// Every hexagram is active. Nothing here shows Chinese; the glyph, number and
// English label carry the whole meaning.
//
// gui props throughout. A quiet control that brightens on approach is
// `Quiet` below — the `.more` role as props — and a SET control states its
// ground and ink and drops the approach. A chip is a word in an outline on top
// of that; a matrix cell is a square whose edge answers approach, as `.leaf`'s
// did. The source also lifted a cell's ground on hover; that half stays dropped.
//
// A `Fit` is one floor and a cap rather than four breakpoints. 260 reproduces
// all four steps of `sm:2 lg:3 xl:4` against this page's own measure (1280,
// 16/32 of gutter) — checked at 390, 640, 1024 and 1280.
import React, { useCallback, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react";
import {
HEXAGRAMS,
GROUPS,
TRIGRAMS,
TRIGRAM_ORDER,
LENSES,
lensCount,
hexagramAt,
displayNum,
englishLabel,
glyphAria,
type LensKey,
type TrigramKey,
} from "./hexagrams";
import { HexagramCard, HexagramDetail, HexagramHead, TrigramLines } from "./HexagramCard";
import { Box, Text, View, XStack, YStack } from '@hanzo/ui'
import { Grid as Columns, tracks } from '@hanzo/ui/grid'
type View = "sequence" | "matrix" | "lenses";
const VIEWS: { key: View; label: string; hint: string }[] = [
{ key: "sequence", label: "Sequence", hint: "All sixty-four, in the traditional King Wen order." },
{ key: "matrix", label: "Matrix", hint: "The eight forces above and below — every pairing is one condition." },
{ key: "lenses", label: "Lenses", hint: "The same patterns, read through four modern minds." },
];
/** The card deck, everywhere it appears. */
const DECK = { min: 260, max: 4 } as const
type TextProps = ComponentProps<typeof Text>
/**
* A quiet control: the `.more` role — muted ink that brightens on approach —
* on a button. `set` is a control that is ON: it carries the white ground and
* the dark ink, and no approach, because it already answered.
*/
function Quiet({ set, children, ...p }: { set?: boolean; children: ReactNode } & Omit<TextProps, 'children'>) {
return (
<Text
display="inline-flex"
items="center"
gap={8}
fontSize="$2"
lineHeight="1.1607rem"
fontWeight="500"
whiteSpace="normal"
cursor="pointer"
color={set ? 'var(--primary-foreground)' : 'var(--neutral-300)'}
bg={set ? 'var(--pure-white)' : undefined}
hoverStyle={set ? undefined : { color: 'var(--pure-white)' }}
$theme-light={set ? undefined : { color: 'var(--muted-foreground)', hoverStyle: { color: 'var(--foreground)' } }}
{...p}
>
{children}
</Text>
)
}
/** A line of small muted type: the hint under the tabs, the note over the matrix. */
function Small({ children, ...p }: { children: ReactNode } & Omit<TextProps, 'children'>) {
return (
<Text render="p" display="block" fontSize="$2" lineHeight="1.3203rem" color="var(--muted-foreground)" {...p}>
{children}
</Text>
)
}
/** The mono label at its two strengths: the ROMAN numeral, and the RANGE a shade dimmer. */
function Mono({ dim, children }: { dim?: boolean; children: ReactNode }) {
return (
<Text
fontFamily="$mono"
fontSize="$1"
lineHeight="0.9167rem"
$platform-web={{ color: `color-mix(in oklab, var(--muted-foreground) ${dim ? 40 : 50}%, transparent)` }}
>
{children}
</Text>
)
}
export default function HexagramExplorer() {
const [view, setView] = useState<View>("sequence");
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
const onTabKey = useCallback(
(e: React.KeyboardEvent, i: number) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "Home" && e.key !== "End") return;
e.preventDefault();
const last = VIEWS.length - 1;
let next = i;
if (e.key === "ArrowRight") next = i === last ? 0 : i + 1;
if (e.key === "ArrowLeft") next = i === 0 ? last : i - 1;
if (e.key === "Home") next = 0;
if (e.key === "End") next = last;
setView(VIEWS[next].key);
tabsRef.current[next]?.focus();
},
[]
);
return (
<Box>
<XStack
role="tablist"
aria-label="Ways to explore the sixty-four patterns"
display="inline-flex"
flexWrap="wrap"
gap={4}
p={4}
borderWidth={1}
borderColor="var(--border)"
rounded="var(--radius-full)"
bg="$panel"
>
{VIEWS.map((v, i) => {
const active = view === v.key;
return (
<Quiet
key={v.key}
render={
<button
ref={(el) => {
tabsRef.current[i] = el;
}}
role="tab"
id={`tab-${v.key}`}
aria-selected={active}
aria-controls={`panel-${v.key}`}
tabIndex={active ? 0 : -1}
onClick={() => setView(v.key)}
onKeyDown={(e) => onTabKey(e, i)}
/>
}
set={active}
minH={44}
px={16}
rounded="var(--radius-full)"
>
{v.label}
</Quiet>
);
})}
</XStack>
<Small mt={16} lineHeight="1.1607rem">
{VIEWS.find((v) => v.key === view)!.hint}
</Small>
<Box mt={32}>
<div role="tabpanel" id="panel-sequence" aria-labelledby="tab-sequence" hidden={view !== "sequence"}>
{view === "sequence" && <SequenceView />}
</div>
<div role="tabpanel" id="panel-matrix" aria-labelledby="tab-matrix" hidden={view !== "matrix"}>
{view === "matrix" && <MatrixView />}
</div>
<div role="tabpanel" id="panel-lenses" aria-labelledby="tab-lenses" hidden={view !== "lenses"}>
{view === "lenses" && <LensesView />}
</div>
</Box>
</Box>
);
}
// ── Sequence ──────────────────────────────────────────────────────
function SequenceView() {
return (
<YStack gap={48}>
{GROUPS.map((g) => (
<section key={g.roman} aria-labelledby={`group-${g.roman}`}>
<XStack
items="baseline"
gap={12}
mb={16}
pb={8}
borderBottomWidth={1}
borderColor="$edge"
>
<Mono>{g.roman}</Mono>
<Text render="h3" id={`group-${g.roman}`} display="block" fontSize="$4" lineHeight="1.4583rem" fontWeight="600" color="var(--foreground)">
{g.name}
</Text>
<Mono dim>
{displayNum(g.from)}–{displayNum(g.to)}
</Mono>
</XStack>
<Columns columns={DECK} gap={12}>
{HEXAGRAMS.filter((h) => h.num >= g.from && h.num <= g.to).map((h) => (
<HexagramCard key={h.num} h={h} />
))}
</Columns>
</section>
))}
</YStack>
);
}
// ── Lenses ────────────────────────────────────────────────────────
/** A lens chip: a word in an outline on a faint ground, white and borderless when it is the lens in use. */
function Chip({ on, onPress, children }: { on: boolean; onPress: () => void; children: ReactNode }) {
return (
<Quiet
render={<button aria-pressed={on} onClick={onPress} />}
set={on}
px={16}
py={6}
borderWidth={1}
borderColor={on ? 'transparent' : 'var(--border)'}
rounded="var(--radius-full)"
$platform-web={on ? undefined : { backgroundColor: 'color-mix(in oklab, var(--secondary) 30%, transparent)' }}
>
{children}
</Quiet>
)
}
function LensesView() {
const [lens, setLens] = useState<LensKey | null>(null);
const active = lens ? LENSES.find((l) => l.key === lens)! : null;
const shown = useMemo(
() =>
lens
? HEXAGRAMS.filter((h) => h.lenses.includes(lens))
: HEXAGRAMS.filter((h) => h.lenses.length > 0),
[lens]
);
return (
<Box>
<XStack flexWrap="wrap" gap={8}>
<Chip on={lens === null} onPress={() => setLens(null)}>
All lensed patterns
</Chip>
{LENSES.map((l) => (
<Chip key={l.key} on={lens === l.key} onPress={() => setLens(l.key)}>
{l.name}{' '}
<Text color="inherit" opacity={0.5}>
{lensCount(l.key)}
</Text>
</Chip>
))}
</XStack>
<Small mt={24} maxW={672}>
{active ? (
<>
<Text fontWeight="500" color="var(--foreground)">
{active.name}.
</Text>{' '}
{active.definition}
</>
) : (
"Four modern minds read the same sixty-four patterns. Select one to see the conditions it illuminates."
)}
</Small>
<Box mt={24}>
<Columns columns={DECK} gap={12}>
{shown.map((h) => (
<HexagramCard key={h.num} h={h} />
))}
</Columns>
</Box>
</Box>
);
}
// ── Matrix ────────────────────────────────────────────────────────
/** A trigram along either axis: quiet, and lit on a white wash when it is the axis in use. */
function Axis({ on, column, title, onPress, children }: { on: boolean; column?: boolean; title: string; onPress: () => void; children: ReactNode }) {
return (
<Quiet
render={<button onClick={onPress} aria-pressed={on} title={title} />}
flexDirection={column ? 'column' : 'row'}
items="center"
justify={column ? undefined : 'center'}
gap={column ? 2 : 8}
py={column ? 4 : undefined}
rounded="var(--radius-md)"
fontSize="$6"
lineHeight="1.0625rem"
color={on ? 'var(--foreground)' : undefined}
hoverStyle={undefined}
$theme-light={undefined}
$platform-web={{
color: on ? undefined : 'color-mix(in oklab, var(--foreground) 70%, transparent)',
backgroundColor: on ? 'color-mix(in oklab, var(--pure-white) 15%, transparent)' : undefined,
}}
>
{children}
</Quiet>
)
}
/** The panel the legend and the selected pattern sit in. */
function Panel({ children, ...p }: { children: ReactNode } & ComponentProps<typeof Box>) {
return (
<Box
rounded="var(--radius-xl)"
borderWidth={1}
borderColor="var(--border)"
{...p}
$platform-web={{ backgroundColor: 'color-mix(in oklab, var(--secondary) 20%, transparent)' }}
>
{children}
</Box>
)
}
function MatrixView() {
const [sel, setSel] = useState<number>(1);
const [axisUpper, setAxisUpper] = useState<TrigramKey | null>(null);
const [axisLower, setAxisLower] = useState<TrigramKey | null>(null);
const [focus, setFocus] = useState<[number, number]>([0, 0]);
const cellRefs = useRef<(HTMLButtonElement | null)[][]>(
TRIGRAM_ORDER.map(() => TRIGRAM_ORDER.map(() => null))
);
const selected = HEXAGRAMS.find((h) => h.num === sel)!;
const inAxis = (upper: TrigramKey, lower: TrigramKey) =>
(axisUpper === null || upper === axisUpper) && (axisLower === null || lower === axisLower);
const moveFocus = useCallback((r: number, c: number) => {
const nr = Math.max(0, Math.min(7, r));
const nc = Math.max(0, Math.min(7, c));
setFocus([nr, nc]);
cellRefs.current[nr][nc]?.focus();
}, []);
const onCellKey = useCallback(
(e: React.KeyboardEvent, r: number, c: number) => {
switch (e.key) {
case "ArrowUp": e.preventDefault(); moveFocus(r - 1, c); break;
case "ArrowDown": e.preventDefault(); moveFocus(r + 1, c); break;
case "ArrowLeft": e.preventDefault(); moveFocus(r, c - 1); break;
case "ArrowRight": e.preventDefault(); moveFocus(r, c + 1); break;
case "Home": e.preventDefault(); moveFocus(r, 0); break;
case "End": e.preventDefault(); moveFocus(r, 7); break;
default: break;
}
},
[moveFocus]
);
return (
// A ROW from 1024, where the source asked for `minmax(0,1fr)` beside a
// 20rem rail. One flex line says the same thing and takes a breakpoint,
// which `Grid`'s track list cannot.
<YStack gap={32} $lg={{ flexDirection: 'row' }}>
<YStack flex={1} minW={0}>
<Small mb={16} maxW={576}>
Each hexagram stacks two trigrams — the lower force (bottom three lines) beneath the upper
force (top three lines). Select a row or column to see how one force meets every other.
</Small>
<Box pb={8} overflowX="auto">
<View
role="group"
aria-label="The sixty-four hexagrams arranged by upper and lower trigram"
display="inline-grid"
gridTemplateColumns="2.75rem repeat(8, minmax(2.5rem, 1fr))"
>
{/* corner */}
<Text
display="flex"
items="flex-end"
justify="center"
pb={4}
fontFamily="$mono"
fontSize={9}
lineHeight="0.7031rem"
textTransform="uppercase"
$platform-web={{ color: 'color-mix(in oklab, var(--muted-foreground) 40%, transparent)' }}
>
U/L
</Text>
{/* column headers = lower trigram */}
{TRIGRAM_ORDER.map((lk) => {
const t = TRIGRAMS[lk];
const on = axisLower === lk;
return (
<Axis key={`col-${lk}`} column on={on} title={`Lower: ${t.name}`} onPress={() => setAxisLower(on ? null : lk)}>
<span role="img" aria-label={`Lower trigram ${t.name}`}>{t.glyph}</span>
</Axis>
);
})}
{/* rows = upper trigram */}
{TRIGRAM_ORDER.map((uk, r) => {
const tu = TRIGRAMS[uk];
const onU = axisUpper === uk;
return (
<React.Fragment key={`row-${uk}`}>
<Axis on={onU} title={`Upper: ${tu.name}`} onPress={() => setAxisUpper(onU ? null : uk)}>
<span role="img" aria-label={`Upper trigram ${tu.name}`}>{tu.glyph}</span>
</Axis>
{TRIGRAM_ORDER.map((lk, c) => {
const h = hexagramAt(uk, lk)!;
const isSel = h.num === sel;
const lit = inAxis(uk, lk);
const edge = isSel ? 'color-mix(in oklab, var(--pure-white) 40%, transparent)' : 'transparent';
return (
<View
key={`cell-${uk}-${lk}`}
render={
<button
ref={(el) => {
cellRefs.current[r][c] = el;
}}
tabIndex={focus[0] === r && focus[1] === c ? 0 : -1}
onFocus={() => setFocus([r, c])}
onKeyDown={(e) => onCellKey(e, r, c)}
onClick={() => setSel(h.num)}
title={`${displayNum(h.num)} · ${englishLabel(h)}`}
aria-label={`${glyphAria(h)}. ${tu.name} over ${TRIGRAMS[lk].name}.`}
/>
}
items="center"
justify="center"
p={0}
aspectRatio={1}
borderWidth={1}
rounded="var(--radius-md)"
opacity={lit ? 1 : 0.25}
cursor="pointer"
hoverStyle={isSel ? undefined : { borderColor: 'var(--border)' }}
$platform-web={{
borderColor: edge,
textAlign: 'center',
backgroundColor: isSel ? 'color-mix(in oklab, var(--pure-white) 10%, transparent)' : 'transparent',
}}
>
<Text role="img" aria-hidden fontSize="$4" lineHeight="0.9375rem" color="var(--foreground)">
{h.glyph}
</Text>
<Text
mt={2}
fontFamily="$mono"
fontSize={9}
$platform-web={{ color: 'color-mix(in oklab, var(--muted-foreground) 60%, transparent)' }}
>
{displayNum(h.num)}
</Text>
</View>
);
})}
</React.Fragment>
);
})}
</View>
</Box>
{/* Trigram legend / how to read */}
<Panel mt={24} p={16}>
<Text
render="p"
display="block"
mb={12}
fontFamily="$mono"
fontSize={11}
textTransform="uppercase"
letterSpacing={0.55}
$platform-web={{ color: 'color-mix(in oklab, var(--muted-foreground) 50%, transparent)' }}
>
The eight trigrams
</Text>
<View render="ul" display="grid" gridTemplateColumns={tracks({ min: 120, max: 4 }, '24px')} columnGap={24} rowGap={8} my={14} pl={40}>
{TRIGRAM_ORDER.map((k) => {
const t = TRIGRAMS[k];
return (
<XStack key={k} render="li" items="center" gap={10}>
<Text
role="img"
aria-label={t.name}
fontSize="$4"
lineHeight="0.9375rem"
$platform-web={{ color: 'color-mix(in oklab, var(--foreground) 80%, transparent)' }}
>
{t.glyph}
</Text>
<TrigramLines lines={t.lines} />
<Text fontSize="$2" lineHeight="1.1607rem" color="var(--muted-foreground)">
{t.name}
</Text>
</XStack>
);
})}
</View>
</Panel>
</YStack>
{/* Selected detail */}
<YStack render="aside" $lg={{ width: 320, grow: 0, shrink: 0, position: 'sticky', t: 96, self: 'flex-start' }}>
<Panel p={20}>
<XStack items="center" gap={16} mb={16}>
<HexagramHead h={selected} glyph="$10" />
</XStack>
<Box borderTopWidth={1} borderColor="$edge" pt={16}>
<HexagramDetail h={selected} />
</Box>
</Panel>
</YStack>
</YStack>
);
}