Files
brainwave/prototype/app.js
T

734 lines
30 KiB
JavaScript
Raw 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 strict";
const SAMPLE_QUESTION = "我是否要接受这次工作调整?怎样做能更稳妥?";
const SAMPLE_LINES = [9, 8, 8, 8, 8, 8];
const HEXAGRAM_NAMES = [
"", "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", "小畜", "履", "泰", "否",
"同人", "大有", "谦", "豫", "随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒", "遁", "大壮", "晋",
"明夷", "家人", "睽", "蹇", "解", "损", "益", "夬", "姤", "萃", "升", "困",
"井", "革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅", "巽", "兑", "涣",
"节", "中孚", "小过", "既济", "未济"
];
// 行为下卦、列为上卦;三位编码均按自下而上排列,1 为阳、0 为阴。
const KING_WEN = {
"111": { "111": 1, "100": 34, "010": 5, "001": 26, "000": 11, "011": 9, "101": 14, "110": 43 },
"100": { "111": 25, "100": 51, "010": 3, "001": 27, "000": 24, "011": 42, "101": 21, "110": 17 },
"010": { "111": 6, "100": 40, "010": 29, "001": 4, "000": 7, "011": 59, "101": 64, "110": 47 },
"001": { "111": 33, "100": 62, "010": 39, "001": 52, "000": 15, "011": 53, "101": 56, "110": 31 },
"000": { "111": 12, "100": 16, "010": 8, "001": 23, "000": 2, "011": 20, "101": 35, "110": 45 },
"011": { "111": 44, "100": 32, "010": 48, "001": 18, "000": 46, "011": 57, "101": 50, "110": 28 },
"101": { "111": 13, "100": 55, "010": 63, "001": 22, "000": 36, "011": 37, "101": 30, "110": 49 },
"110": { "111": 10, "100": 54, "010": 60, "001": 41, "000": 19, "011": 61, "101": 38, "110": 58 }
};
const icons = {
lock: '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8.5 10V7.5a3.5 3.5 0 017 0V10"/></svg>',
arrow: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>',
chevron: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>',
shield: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.7-2.8 8-7 10-4.2-2-7-5.3-7-10V6l7-3z"/><path d="M9.2 12l1.8 1.8 3.9-4"/></svg>'
};
const screen = document.querySelector("#screen");
const modalRoot = document.querySelector("#modal-root");
const toastRegion = document.querySelector("#toast-region");
const backButton = document.querySelector("[data-action='back']");
const state = {
screen: "welcome",
question: "",
lines: [],
coins: [null, null, null],
modal: null,
consent: false,
explanationSource: "ai",
aiError: false
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function lineIsYang(value) {
return value === 7 || value === 9;
}
function lineIsMoving(value) {
return value === 6 || value === 9;
}
function transformLines(lines) {
return lines.map((value) => {
if (value === 6) return 7;
if (value === 9) return 8;
return value;
});
}
function lookupHexagram(lines) {
const bits = lines.map((value) => lineIsYang(value) ? "1" : "0");
const lower = bits.slice(0, 3).join("");
const upper = bits.slice(3, 6).join("");
const number = KING_WEN[lower][upper];
return { number, name: HEXAGRAM_NAMES[number] };
}
function lineName(value, index) {
const position = ["初", "二", "三", "四", "五", "上"][index];
const polarity = lineIsYang(value) ? "九" : "六";
if (index === 0) return `${position}${polarity}`;
if (index === 5) return `${position}${polarity}`;
return `${polarity}${position}`;
}
function resultData() {
const sourceLines = state.lines.length === 6 ? state.lines : SAMPLE_LINES;
const changedLines = transformLines(sourceLines);
return {
sourceLines,
changedLines,
primary: lookupHexagram(sourceLines),
changed: lookupHexagram(changedLines),
movingIndexes: sourceLines
.map((value, index) => lineIsMoving(value) ? index : -1)
.filter((index) => index >= 0)
};
}
function stepper(current, total) {
const progress = Math.round((current / total) * 100);
return `
<div class="stepper" aria-label="第 ${current} 步,共 ${total} 步">
<span>${String(current).padStart(2, "0")}</span>
<span class="stepper__track" style="--progress:${progress}%"></span>
<span>${String(total).padStart(2, "0")}</span>
</div>`;
}
function welcomeView() {
return `
<section class="page page--welcome" aria-labelledby="welcome-title">
<div>
<p class="eyebrow">慢一点,看清当下</p>
<h1 id="welcome-title">把一个犹豫,安静地放在这里。</h1>
<p class="lede">用你手中的三枚硬币,依次记录六次结果。这里提供文化文本与反思线索,不替你预测,也不替你决定。</p>
<div class="hero-mark" aria-hidden="true">
<span class="hero-mark__dot">观</span>
</div>
<ul class="principles" aria-label="产品原则">
<li>手动记录</li>
<li>本地成卦</li>
<li>选择解读</li>
</ul>
</div>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="begin">
写下此刻所问 ${icons.arrow}
</button>
<p class="fine-print" style="margin:0;text-align:center">工作名“一问”仅用于本轮视觉评审</p>
</div>
</section>`;
}
function questionView() {
return `
<section class="page" aria-labelledby="question-title">
${stepper(1, 2)}
<p class="eyebrow">起念</p>
<h1 id="question-title">你想看清什么?</h1>
<p class="lede">写成一个开放的问题,比只问“会不会”更容易照见可行动的部分。</p>
<label class="field-label" for="question-input">此刻的问题</label>
<textarea
id="question-input"
class="question-input"
maxlength="120"
placeholder="例如:面对这次工作调整,我怎样做能更稳妥?"
data-question-input
>${escapeHtml(state.question)}</textarea>
<div class="field-meta">
<span>只保存在本次原型会话中</span>
<span data-char-count>${state.question.length}/120</span>
</div>
<div class="privacy-note">
${icons.lock}
<p>起卦计算在本地完成。只有你主动选择 AI 解读并再次确认后,所需内容才会被发送。</p>
</div>
<button class="text-link" type="button" data-action="skip-question">不写具体内容,直接开始</button>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="start-casting" ${state.question.trim() ? "" : "disabled"}>
开始记录六爻 ${icons.arrow}
</button>
</div>
</section>`;
}
function lineStack(lines) {
return [5, 4, 3, 2, 1, 0].map((index) => {
const value = lines[index];
if (!value) {
return '<div class="line-slot line-slot--empty" aria-label="尚未记录"></div>';
}
const kind = lineIsYang(value) ? "yang" : "yin";
const moving = lineIsMoving(value) ? " line-slot--moving" : "";
return `<div class="line-slot line-slot--${kind}${moving}" aria-label="${lineName(value, index)},数值 ${value}${lineIsMoving(value) ? ",动爻" : ""}">
${lineIsMoving(value) ? `<span class="line-slot__number">${value}</span>` : ""}
</div>`;
}).join("");
}
function coinButton(value, index) {
const face = value === 2 ? "字" : value === 3 ? "背" : "点选";
const score = value ? String(value) : "字 ↔ 背";
const valueAttr = value ? `data-value="${value}"` : "";
const next = value === null ? "字,2 分" : value === 2 ? "背,3 分" : "字,2 分";
return `
<div class="coin-wrap">
<button
class="coin"
type="button"
data-action="toggle-coin"
data-index="${index}"
${valueAttr}
aria-label="第 ${index + 1} 枚硬币,当前${value ? `${face},${score} 分` : "未记录"};点击切换为${next}"
>
<span class="coin__value">
<span class="coin__face">${face}</span>
<span class="coin__score">${score}</span>
</span>
</button>
<span>硬币 ${index + 1}</span>
</div>`;
}
function castingView() {
const round = state.lines.length + 1;
const selected = state.coins.every((value) => value !== null);
const sum = selected ? state.coins.reduce((total, value) => total + value, 0) : null;
const currentType = sum
? ({ 6: "老阴 · 动", 7: "少阳", 8: "少阴", 9: "老阳 · 动" })[sum]
: "";
return `
<section class="page page--compact" aria-labelledby="casting-title">
${stepper(2, 2)}
<div class="question-chip" title="${escapeHtml(state.question)}">${escapeHtml(state.question)}</div>
<div class="casting-layout">
<div class="line-progress-card">
<div class="line-progress-copy">
<strong>${state.lines.length} / 6</strong>
<p>从下往上记录</p>
${state.lines.length ? '<button class="text-link" type="button" data-action="undo-line">撤销上一爻</button>' : ""}
</div>
<div class="line-stack" aria-label="已记录的六爻">${lineStack(state.lines)}</div>
</div>
<div class="coin-prompt">
<h2 id="casting-title">第 ${round} 爻</h2>
<p>依照手中硬币,逐枚点选“字”或“背”</p>
</div>
<div class="coins" aria-label="三枚硬币录入">
${state.coins.map(coinButton).join("")}
</div>
<div class="round-sum" aria-live="polite">
${sum ? `本次合计 <strong>${sum}</strong> · ${currentType}` : "点按硬币可在“字 2”与“背 3”间切换"}
</div>
</div>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="confirm-line" ${selected ? "" : "disabled"}>
${round === 6 ? "记录并查看结果" : `确认第 ${round} 爻`} ${icons.arrow}
</button>
</div>
</section>`;
}
function hexLine(value, index, showMoving) {
const kind = lineIsYang(value) ? "yang" : "yin";
const moving = showMoving && lineIsMoving(value) ? " hex-line--moving" : "";
return `<div class="hex-line hex-line--${kind}${moving}" aria-label="${lineName(value, index)}${moving ? ",动爻" : ""}"><span></span><span></span></div>`;
}
function hexagramFigure(lines, hexagram, showMoving) {
const rendered = [5, 4, 3, 2, 1, 0]
.map((index) => hexLine(lines[index], index, showMoving))
.join("");
return `
<div class="hexagram-view">
<div class="hexagram-lines" role="img" aria-label="第 ${hexagram.number} 卦,${hexagram.name}卦">${rendered}</div>
<div class="hexagram-name">${hexagram.name}</div>
<div class="hexagram-number">第 ${hexagram.number} 卦</div>
</div>`;
}
function resultCopy(data) {
if (data.primary.number === 24) {
return {
classic: "复:亨。出入无疾,朋来无咎。反复其道,七日来复。利有攸往。",
plain: "“复”提醒人把注意力放回可返回、可重新开始之处。它不是替你判断成败,而是邀请你看见:此刻是否有一条更朴素、更合乎本心的路。",
moving: "初九:不远复,无祇悔,元吉。",
movingPlain: "偏离尚不远时就察觉并回转,代价通常较小。先修正一个最近的动作,比一次解决所有问题更可行。"
};
}
return {
classic: `${data.primary.name}卦 · 第 ${data.primary.number} 卦。本原型仅演示排盘与阅读层级,正式经典文本将从经校勘的本地内容包读取。`,
plain: "先观察卦象呈现的张力与变化,再回到你能核实的事实、感受和资源。不要把符号当成替你下结论的证据。",
moving: data.movingIndexes.length ? `${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join("、")}发生变化。` : "",
movingPlain: "变化的位置可作为反思入口:哪里正在松动,哪里仍需要保留余地?"
};
}
function errorCard() {
return `
<div class="error-card" role="alert">
<h3>AI 解读暂时没有返回</h3>
<p class="quiet">起卦结果仍完整保存在本页。你可以改看本地解读,或稍后重试。</p>
<div class="error-actions">
<button class="button button--secondary" type="button" data-action="choose-local">本地解读</button>
<button class="button button--danger-quiet" type="button" data-action="retry-ai">重试</button>
</div>
</div>`;
}
function resultView() {
const data = resultData();
const copy = resultCopy(data);
const hasMoving = data.movingIndexes.length > 0;
const question = state.question || "未记录具体问题";
return `
<section class="page page--compact" aria-labelledby="result-title">
<div class="result-hero">
<div class="result-kicker">
<span>起卦结果 · 本地计算</span>
<span>${hasMoving ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
</div>
<div class="hexagram-pair${hasMoving ? "" : " hexagram-pair--single"}">
${hexagramFigure(data.sourceLines, data.primary, true)}
${hasMoving ? `<div class="transform-arrow" aria-label="变化为">之</div>${hexagramFigure(data.changedLines, data.changed, false)}` : ""}
</div>
${hasMoving ? "" : '<div class="static-indicator">卦象稳定 · 不显示之卦</div>'}
</div>
<div class="content-section">
<div class="section-heading">
<h2 id="result-title">你所问</h2>
<span class="source-badge">本次记录</span>
</div>
<p class="question-chip" style="white-space:normal">${escapeHtml(question)}</p>
</div>
${state.aiError ? errorCard() : ""}
<div class="content-section">
<div class="section-heading">
<h2>经典原文</h2>
<span class="source-badge">经典文本</span>
</div>
<blockquote class="classic-quote">${copy.classic}</blockquote>
</div>
<div class="content-section">
<div class="section-heading">
<h2>本地白话</h2>
<span class="source-badge">本地内容</span>
</div>
<div class="plain-copy"><p>${copy.plain}</p></div>
</div>
${hasMoving ? `
<div class="content-section">
<div class="section-heading"><h2>动爻</h2><span class="source-badge">变化处</span></div>
<div class="moving-card">
<div class="moving-card__label">${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join(" · ")}</div>
<h3>${copy.moving}</h3>
<p class="quiet">${copy.movingPlain}</p>
</div>
</div>` : ""}
<hr class="divider" />
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="open-interpretation">
解 · 选择一种解读方式 ${icons.arrow}
</button>
</div>
</section>`;
}
function loadingView() {
return `
<section class="page" aria-labelledby="loading-title">
<div class="loading-state">
<div class="loading-orbit" aria-hidden="true"></div>
<h1 id="loading-title" style="font-size:27px">正在整理反思线索</h1>
<p class="lede">结果已经生成。AI 只在此处协助组织文字,不参与起卦。</p>
</div>
</section>`;
}
function explanationView() {
const data = resultData();
const ai = state.explanationSource === "ai";
return `
<article class="page page--compact" aria-labelledby="explanation-title">
<header class="interpretation-header">
<p class="eyebrow">${ai ? "辅助反思" : "离线阅读"}</p>
<h1 id="explanation-title">从“回到起点”开始</h1>
<div class="interpretation-meta">
<span class="source-badge">${ai ? "AI 生成" : "本地解读"}</span>
<span>${data.primary.name}卦 · 第 ${data.primary.number} 卦</span>
</div>
</header>
<p class="interpretation-lead">眼前更重要的,也许不是立刻选定一条路,而是辨认哪一步能让你重新获得稳定与判断力。</p>
<section class="content-section" aria-labelledby="observation-title">
<div class="section-heading"><h2 id="observation-title">可以先观察</h2></div>
<ul class="reflection-list">
<li>你对“调整”的担心里,哪些是已经发生的事实,哪些是尚未验证的推测?</li>
<li>复卦的动处在最初一爻,适合把注意力放在能尽早修正的小偏差。</li>
<li>之卦为坤,提示行动时给现实条件、协作关系与节奏留出空间。</li>
</ul>
</section>
<section class="content-section" aria-labelledby="questions-title">
<div class="section-heading"><h2 id="questions-title">问问自己</h2></div>
<ol class="question-list">
<li>如果暂时不追求一次做对,我最想先确认的一个事实是什么?</li>
<li>哪一种选择更容易保留回转余地,而不是把自己锁死?</li>
<li>我可以向谁说明顾虑,并获得一条具体信息?</li>
</ol>
</section>
<section class="content-section" aria-labelledby="step-title">
<div class="small-step">
<div class="small-step__label" id="step-title">可以试的一小步</div>
<p>在今天结束前,写下“已知事实、尚待确认、我的底线”各一条,再约一次不超过 20 分钟的信息沟通。</p>
</div>
</section>
<p class="safety-note">这是一种文化反思与文字整理,不是预测、诊断或替代你的判断。涉及医疗、法律、财务或人身安全时,请寻求相应专业支持。</p>
<div class="bottom-action">
<button class="button button--secondary" type="button" data-action="back-to-result">回到起卦结果</button>
</div>
</article>`;
}
function interpretationModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="interpretation-modal-title" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="interpretation-modal-title">选择解读方式</h2>
<p>起卦结果不会改变,只切换阅读材料的来源。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="option-list">
<button class="option-card" type="button" data-action="choose-local">
<span class="option-card__icon" aria-hidden="true">本</span>
<span><strong>先看本地解读</strong><small>离线可用,不发送任何内容</small></span>
${icons.chevron}
</button>
<button class="option-card" type="button" data-action="choose-ai">
<span class="option-card__icon" aria-hidden="true">解</span>
<span><strong>使用 AI 辅助解读</strong><small>确认发送范围后再开始</small></span>
${icons.chevron}
</button>
</div>
</section>
</div>`;
}
function consentModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="consent-modal-title" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="consent-modal-title">发送前,请你确认</h2>
<p>AI 不参与起卦,只根据既有结果整理反思线索。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="notice">
${icons.shield}
<p>将发送以下内容;不会发送设备标识、历史记录或其他本地数据。</p>
</div>
<ul class="data-list">
<li>你本次写下的问题</li>
<li>本卦、之卦与动爻结果</li>
<li>用于约束语气和安全边界的提示</li>
</ul>
<label class="consent-check">
<input type="checkbox" data-action="consent-toggle" ${state.consent ? "checked" : ""} />
<span>我已了解发送范围,并同意仅为本次 AI 解读使用这些内容。</span>
</label>
<div class="modal-actions">
<button class="button button--primary" type="button" data-action="confirm-ai" ${state.consent ? "" : "disabled"}>同意并开始解读</button>
<button class="button button--quiet" type="button" data-action="choose-local">改看本地解读</button>
</div>
</section>
</div>`;
}
function methodModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="method-modal-title" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="method-modal-title">三枚硬币法</h2>
<p>原型采用明确、可复核的固定规则。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<ol class="method-steps">
<li>准备三枚硬币,在现实中投掷一次。</li>
<li>在页面逐枚记录字面或背面,确认后形成一爻。</li>
<li>从下往上重复六次;程序只换算,不替你随机生成。</li>
</ol>
<div class="score-table" aria-label="硬币计分规则">
<div><strong>字 · 2</strong><span>字面记两分</span></div>
<div><strong>背 · 3</strong><span>背面记三分</span></div>
</div>
<p class="fine-print">合计 6 为老阴、7 为少阳、8 为少阴、9 为老阳;6 与 9 是动爻。</p>
<button class="button button--secondary" style="width:100%" type="button" data-action="close-modal">知道了</button>
</section>
</div>`;
}
function renderModal() {
if (state.modal === "interpretation") modalRoot.innerHTML = interpretationModal();
else if (state.modal === "consent") modalRoot.innerHTML = consentModal();
else if (state.modal === "method") modalRoot.innerHTML = methodModal();
else modalRoot.innerHTML = "";
document.body.style.overflow = state.modal ? "hidden" : "";
if (state.modal) {
window.setTimeout(() => {
modalRoot.querySelector("button, input")?.focus();
}, 20);
}
}
function render(options = {}) {
if (state.screen === "welcome") screen.innerHTML = welcomeView();
else if (state.screen === "question") screen.innerHTML = questionView();
else if (state.screen === "casting") screen.innerHTML = castingView();
else if (state.screen === "result") screen.innerHTML = resultView();
else if (state.screen === "loading") screen.innerHTML = loadingView();
else if (state.screen === "explanation") screen.innerHTML = explanationView();
backButton.hidden = state.screen === "welcome";
renderModal();
if (!options.preserveFocus) {
screen.focus({ preventScroll: true });
window.scrollTo({ top: 0, behavior: "auto" });
}
}
function showToast(message) {
toastRegion.innerHTML = `<div class="toast">${escapeHtml(message)}</div>`;
window.setTimeout(() => {
toastRegion.innerHTML = "";
}, 1800);
}
function closeModal() {
state.modal = null;
state.consent = false;
renderModal();
}
function goBack() {
if (state.modal) {
closeModal();
return;
}
if (state.screen === "question") state.screen = "welcome";
else if (state.screen === "casting") state.screen = "question";
else if (state.screen === "result") state.screen = "question";
else if (state.screen === "loading" || state.screen === "explanation") state.screen = "result";
render();
}
function beginAiExplanation() {
state.modal = null;
state.screen = "loading";
state.explanationSource = "ai";
render();
window.setTimeout(() => {
if (state.screen !== "loading") return;
state.screen = "explanation";
render();
}, 900);
}
document.addEventListener("input", (event) => {
if (!event.target.matches("[data-question-input]")) return;
state.question = event.target.value;
document.querySelector("[data-char-count]").textContent = `${state.question.length}/120`;
const startButton = document.querySelector("[data-action='start-casting']");
if (startButton) startButton.disabled = !state.question.trim();
});
document.addEventListener("change", (event) => {
if (!event.target.matches("[data-action='consent-toggle']")) return;
state.consent = event.target.checked;
const confirm = document.querySelector("[data-action='confirm-ai']");
if (confirm) confirm.disabled = !state.consent;
});
document.addEventListener("click", (event) => {
const actionTarget = event.target.closest("[data-action]");
if (!actionTarget) return;
const action = actionTarget.dataset.action;
if (action === "dismiss-modal" && event.target.closest("[data-modal-sheet]")) return;
if (action === "back") goBack();
else if (action === "begin") {
state.screen = "question";
render();
} else if (action === "skip-question") {
state.question = "未记录具体问题(仅作自我观察)";
state.lines = [];
state.coins = [null, null, null];
state.screen = "casting";
render();
} else if (action === "start-casting") {
if (!state.question.trim()) return;
state.lines = [];
state.coins = [null, null, null];
state.screen = "casting";
render();
} else if (action === "toggle-coin") {
const index = Number(actionTarget.dataset.index);
const current = state.coins[index];
state.coins[index] = current === null || current === 3 ? 2 : 3;
render({ preserveFocus: true });
document.querySelector(`[data-action='toggle-coin'][data-index='${index}']`)?.focus();
} else if (action === "confirm-line") {
if (!state.coins.every((value) => value !== null)) return;
state.lines.push(state.coins.reduce((total, value) => total + value, 0));
state.coins = [null, null, null];
if (state.lines.length === 6) {
state.screen = "result";
}
render();
} else if (action === "undo-line") {
state.lines.pop();
state.coins = [null, null, null];
render();
showToast("已撤销上一爻");
} else if (action === "open-interpretation") {
state.modal = "interpretation";
renderModal();
} else if (action === "choose-ai") {
state.modal = "consent";
state.consent = false;
renderModal();
} else if (action === "choose-local") {
state.modal = null;
state.explanationSource = "local";
state.screen = "explanation";
render();
} else if (action === "confirm-ai") {
if (!state.consent) return;
beginAiExplanation();
} else if (action === "retry-ai") {
state.aiError = false;
state.modal = "consent";
state.consent = false;
renderModal();
} else if (action === "restart") {
state.question = "";
state.lines = [];
state.coins = [null, null, null];
state.aiError = false;
state.screen = "welcome";
render();
} else if (action === "back-to-result") {
state.screen = "result";
render();
} else if (action === "open-method") {
state.modal = "method";
renderModal();
} else if (action === "close-modal" || action === "dismiss-modal") {
closeModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.modal) closeModal();
});
function seedPreview() {
const view = new URLSearchParams(window.location.search).get("view");
if (!view || view === "welcome") return;
if (view === "question") {
state.screen = "question";
state.question = SAMPLE_QUESTION;
return;
}
state.question = SAMPLE_QUESTION;
if (view === "casting") {
state.screen = "casting";
state.lines = [9, 8];
state.coins = [2, 3, null];
return;
}
state.lines = view === "static" ? [7, 7, 7, 7, 7, 7] : [...SAMPLE_LINES];
state.screen = "result";
if (view === "consent") state.modal = "consent";
else if (view === "explanation") {
state.screen = "explanation";
state.explanationSource = "ai";
} else if (view === "local") {
state.screen = "explanation";
state.explanationSource = "local";
} else if (view === "error") {
state.aiError = true;
}
}
seedPreview();
render();
})();