mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
527 lines
19 KiB
TypeScript
527 lines
19 KiB
TypeScript
'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>
|
||
);
|
||
}
|