From b3339adbd0dd027614ec1f095f1bffa1ab392641 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Thu, 6 Aug 2026 16:54:06 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor(mobile):=20=E4=BE=9D=20FP=20?= =?UTF-8?q?=E7=9A=84=20Actions/Calculations/Data=20=E6=8B=86=E5=88=86=20ch?= =?UTF-8?q?artPdf.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本 1220 行的 chartPdf.ts 混雜 HTML/SVG 產生(純計算)與 expo-print/ expo-sharing 的實際下載動作,拆成 lib/pdf/ 目錄:純函式(palette/ bodyGraphSvg/htmlHelpers/三種報告的 HTML 模板/AI 提示詞產生)與唯一 含副作用的 actions.ts 分開,4 個呼叫端改為直接 import 新路徑。 Co-Authored-By: Claude Sonnet 5 --- mobile/app/chart/[id].tsx | 10 +- mobile/app/chart/preview.tsx | 3 +- mobile/components/CompositeView.tsx | 3 +- mobile/components/TransitView.tsx | 3 +- mobile/lib/chartPdf.ts | 1220 --------------------------- mobile/lib/pdf/actions.ts | 50 ++ mobile/lib/pdf/aiPrompts.ts | 281 ++++++ mobile/lib/pdf/bodyGraphSvg.ts | 206 +++++ mobile/lib/pdf/compositeHtml.ts | 267 ++++++ mobile/lib/pdf/htmlHelpers.ts | 67 ++ mobile/lib/pdf/palette.ts | 20 + mobile/lib/pdf/personalChartHtml.ts | 180 ++++ mobile/lib/pdf/transitHtml.ts | 175 ++++ mobile/lib/pdf/types.ts | 4 + 14 files changed, 1258 insertions(+), 1231 deletions(-) delete mode 100644 mobile/lib/chartPdf.ts create mode 100644 mobile/lib/pdf/actions.ts create mode 100644 mobile/lib/pdf/aiPrompts.ts create mode 100644 mobile/lib/pdf/bodyGraphSvg.ts create mode 100644 mobile/lib/pdf/compositeHtml.ts create mode 100644 mobile/lib/pdf/htmlHelpers.ts create mode 100644 mobile/lib/pdf/palette.ts create mode 100644 mobile/lib/pdf/personalChartHtml.ts create mode 100644 mobile/lib/pdf/transitHtml.ts create mode 100644 mobile/lib/pdf/types.ts diff --git a/mobile/app/chart/[id].tsx b/mobile/app/chart/[id].tsx index 7415d4a..44d7304 100644 --- a/mobile/app/chart/[id].tsx +++ b/mobile/app/chart/[id].tsx @@ -14,14 +14,8 @@ import { type StoredPlanet, isCompositeChart, isLegacyPipeComposite } from '@/li import { ACT_CONSCIOUS, ACT_UNCONSCIOUS } from '@shared/humanDesign/hd-chart-data' import { normalizeCenterId, normalizeChannelId } from '@/lib/hd-normalizers' import { useChartDetail } from '@/lib/useChartDetail' -import { - downloadChartAsPdf, - generateAiPrompt, - downloadCompositePdf, - generateCompositeAiPrompt, - downloadTransitPdf, - generateTransitAiPrompt, -} from '@/lib/chartPdf' +import { downloadChartAsPdf, downloadCompositePdf, downloadTransitPdf } from '@/lib/pdf/actions' +import { generateAiPrompt, generateCompositeAiPrompt, generateTransitAiPrompt } from '@/lib/pdf/aiPrompts' import BodyGraph from '@/components/BodyGraph' import DetailBottomSheet, { type SheetTarget } from '@/components/DetailBottomSheet' import { SectionCard, Row, ActionButton, type ActionState } from '@/components/chart/ChartPrimitives' diff --git a/mobile/app/chart/preview.tsx b/mobile/app/chart/preview.tsx index 60ff07b..e655617 100644 --- a/mobile/app/chart/preview.tsx +++ b/mobile/app/chart/preview.tsx @@ -13,7 +13,8 @@ import { import { SafeAreaView } from 'react-native-safe-area-context' import { getPendingChart, clearPendingChart, type PendingChart } from '@/lib/pendingChart' import { createChart } from '@/lib/api' -import { downloadChartAsPdf, generateAiPrompt } from '@/lib/chartPdf' +import { downloadChartAsPdf } from '@/lib/pdf/actions' +import { generateAiPrompt } from '@/lib/pdf/aiPrompts' import { HD_CENTERS_INFO, ACT_CONSCIOUS, ACT_UNCONSCIOUS } from '@shared/humanDesign/hd-chart-data' import { normalizeCenterId, normalizeChannelId, findChannelById } from '@/lib/hd-normalizers' import { getTypeMeta, getTypeLabel } from '@/lib/hd-type-meta' diff --git a/mobile/components/CompositeView.tsx b/mobile/components/CompositeView.tsx index 3e5d86d..2305e50 100644 --- a/mobile/components/CompositeView.tsx +++ b/mobile/components/CompositeView.tsx @@ -21,7 +21,8 @@ import { previewCompositeChart, createCompositeChart, } from '@/lib/api' -import { downloadCompositePdf, generateCompositeAiPrompt } from '@/lib/chartPdf' +import { downloadCompositePdf } from '@/lib/pdf/actions' +import { generateCompositeAiPrompt } from '@/lib/pdf/aiPrompts' import { buildCompositeBodyGraphProps } from '@/lib/hd-bodygraph-utils' import BirthDataForm, { type BirthFormData, defaultBirthFormData } from '@/components/BirthDataForm' import { BirthProfilePickerModal } from '@/components/BirthProfilePickerModal' diff --git a/mobile/components/TransitView.tsx b/mobile/components/TransitView.tsx index 8bba060..8fb92b7 100644 --- a/mobile/components/TransitView.tsx +++ b/mobile/components/TransitView.tsx @@ -17,7 +17,8 @@ import { } from 'react-native' import { type CreateTransitResult, previewTransitChart, createTransitChart } from '@/lib/api' import { ScrollLockContext, useScrollLockState } from '@/contexts/ScrollLockContext' -import { downloadTransitPdf, generateTransitAiPrompt } from '@/lib/chartPdf' +import { downloadTransitPdf } from '@/lib/pdf/actions' +import { generateTransitAiPrompt } from '@/lib/pdf/aiPrompts' import { buildTransitBodyGraphProps } from '@/lib/hd-bodygraph-utils' import { useBirthProfiles } from '@/hooks/useBirthProfiles' import { useKeyboardHeight } from '@/hooks/useKeyboardHeight' diff --git a/mobile/lib/chartPdf.ts b/mobile/lib/chartPdf.ts deleted file mode 100644 index 5389aad..0000000 --- a/mobile/lib/chartPdf.ts +++ /dev/null @@ -1,1220 +0,0 @@ -import * as Print from 'expo-print' -import * as Sharing from 'expo-sharing' -import { File, Paths } from 'expo-file-system' -import { - ACT_CONSCIOUS, - ACT_UNCONSCIOUS, - CENTER_ORDER, - CENTERS_GEOM, - HD_CENTERS_INFO, - HD_CHANNELS, - HD_GATES, - HD_PALETTE, - INTEGRATION_PAIRS, -} from '@shared/humanDesign/hd-chart-data' -import { findChannelById, normalizeCenterId } from './hd-normalizers' -import { - STRATEGY_MAP, - SIGNATURE_MAP, - AUTHORITY_TIP, - CENTER_NAME, - ALL_CENTER_IDS, - normalizeCenterAlias, -} from './hd-constants' -import { getTypeLabel, getTypeMeta } from './hd-type-meta' -import type { PendingChart } from './pendingChart' -import type { CreateCompositeResult, CreateTransitResult, ConnectionDynamic } from './api' -import { lightColors, darkColors } from '../constants/tokens' -import { CONN_LABEL, CONN_DESC, INTEGRATION_THEME, PROFILE_RESONANCE_DESC } from '../components/composite/compositeText' -import { IMPACT_LABEL, IMPACT_DESC, type ImpactKind } from '../components/transit/ImpactCard' - -export type PdfThemeMode = 'light' | 'dark' - -/** PDF 文件外觀(背景/文字/邊框等版面色)跟著 app 目前的明暗主題走,圖表本身的閘門/中心配色不受影響 */ -function pdfPalette(mode: PdfThemeMode) { - const c = mode === 'dark' ? darkColors : lightColors - return { - bg: c.bg, - ink: c.text, - crimson: c.accent, - sub: c.sub, - cardBg: c.surface, - border: c.border, - dimBg: c.gateBg, - altRow: c.altRowBg, - paperDeep: c.gateBg, // 對應網頁版 --paper-deep,用於頁尾商標色帶 - accentBg: c.accentD, // 對應畫面 Tag active 的底色 - dimText: c.planetRedText, // 對應畫面 Row dim 的文字色 - } -} - -// ─── Gate activation state ───────────────────────────────────────────────────── - -type GateActivation = { c?: boolean; u?: boolean } - -function buildActivations(chart: PendingChart): Record { - const act: Record = {} - if (chart.planets && chart.planets.length > 0) { - for (const p of chart.planets) { - act[p.blackGate] = { ...act[p.blackGate], c: true } - act[p.redGate] = { ...act[p.redGate], u: true } - } - } else { - const pg = chart.personalityGates ?? [] - const dg = chart.designGates ?? [] - if (pg.length > 0 || dg.length > 0) { - for (const g of pg) act[g] = { ...act[g], c: true } - for (const g of dg) act[g] = { ...act[g], u: true } - } else { - for (const g of chart.gates) act[g] = { c: true } - } - } - return act -} - -function actFill(state: GateActivation | undefined): string | null { - if (!state) return null - if (state.c && state.u) return 'both' - if (state.c) return ACT_CONSCIOUS - if (state.u) return ACT_UNCONSCIOUS - return null -} - -function gateLoc(num: number): [number, number] | null { - for (const c of Object.values(CENTERS_GEOM)) { - if (c.gateAnchors[num]) return c.gateAnchors[num] - } - return null -} - -function perpFoot(p: [number, number], a: [number, number], b: [number, number]): [number, number] { - const dx = b[0] - a[0], dy = b[1] - a[1] - const lenSq = dx * dx + dy * dy - const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq - return [a[0] + t * dx, a[1] + t * dy] -} - -// ─── SVG channel segment (HTML SVG equivalent of ChannelSegment) ─────────────── - -function channelSeg(x1: number, y1: number, x2: number, y2: number, fill: string | null, sw: number): string { - if (fill === 'both') { - return ` - - ` - } - if (fill) { - return `` - } - return ` - - ` -} - -// ─── Build BodyGraph SVG string ──────────────────────────────────────────────── - -function buildBodyGraphSvg(chart: PendingChart, presetActivations?: Record): string { - // 流日圖的黑/紅語意跟個人圖不同(個人 vs 今日流日,而非 Personality vs Design), - // 呼叫端會算好對應的 activations 直接傳入,蓋掉這裡預設的 personality/design 判斷法 - const act = presetActivations ?? buildActivations(chart) - const definedCenterIds = new Set(chart.centers.map(s => s.toLowerCase().replace(/\s+/g, ''))) - - // Normalise center keys coming from API (e.g. "Solar Plexus" → "solar") - const CENTER_KEY_MAP: Record = { - 'head': 'head', 'crown': 'head', - 'ajna': 'ajna', 'mind': 'ajna', - 'throat': 'throat', - 'g': 'g', 'genter': 'g', 'gcenter': 'g', 'identity': 'g', - 'heart': 'heart', 'will': 'heart', 'ego': 'heart', - 'spleen': 'spleen', - 'sacral': 'sacral', - 'solar': 'solar', 'solarplexus': 'solar', 'emotionalcenter': 'solar', - 'root': 'root', - } - - function isDefined(k: string): boolean { - // direct match - if (definedCenterIds.has(k)) return true - // try normalising each element of chart.centers - for (const c of chart.centers) { - const norm = c.toLowerCase().replace(/[\s_-]/g, '') - const mapped = CENTER_KEY_MAP[norm] - if (mapped === k) return true - } - return false - } - - const SW = 10 - - // ── Channels ────────────────────────────────────────────────────────────────── - const seenPairs = new Set() - const drawnChannels = HD_CHANNELS.filter(ch => { - const key = `${Math.min(ch.from, ch.to)}-${Math.max(ch.from, ch.to)}` - if (seenPairs.has(key)) return false - seenPairs.add(key) - return true - }) - - let channelsSvg = '' - for (const ch of drawnChannels) { - const a = gateLoc(ch.from), b = gateLoc(ch.to) - if (!a || !b) continue - const pairKey = `${Math.min(ch.from, ch.to)}-${Math.max(ch.from, ch.to)}` - if (INTEGRATION_PAIRS.has(pairKey)) continue - - const aFill = actFill(act[ch.from]) - const bFill = actFill(act[ch.to]) - const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2 - channelsSvg += channelSeg(a[0], a[1], mx, my, aFill, SW) - channelsSvg += channelSeg(mx, my, b[0], b[1], bFill, SW) - } - - // Integration compound - const g20 = gateLoc(20), g57 = gateLoc(57), g10 = gateLoc(10), g34 = gateLoc(34) - if (g20 && g57 && g10 && g34) { - const foot10 = perpFoot(g10, g20, g57) - const foot34 = perpFoot(g34, g20, g57) - const fill20 = actFill(act[20]), fill57 = actFill(act[57]) - const fill10 = actFill(act[10]), fill34 = actFill(act[34]) - const tmx = (g20[0] + g57[0]) / 2, tmy = (g20[1] + g57[1]) / 2 - const s10mx = (g10[0] + foot10[0]) / 2, s10my = (g10[1] + foot10[1]) / 2 - const s34mx = (g34[0] + foot34[0]) / 2, s34my = (g34[1] + foot34[1]) / 2 - channelsSvg += channelSeg(g20[0], g20[1], tmx, tmy, fill20, SW) - channelsSvg += channelSeg(tmx, tmy, g57[0], g57[1], fill57, SW) - channelsSvg += channelSeg(g10[0], g10[1], s10mx, s10my, fill10, SW) - channelsSvg += channelSeg(s10mx, s10my, foot10[0], foot10[1], null, SW) - channelsSvg += channelSeg(g34[0], g34[1], s34mx, s34my, fill34, SW) - channelsSvg += channelSeg(s34mx, s34my, foot34[0], foot34[1], null, SW) - channelsSvg += `` - channelsSvg += `` - } - - // ── Centers ──────────────────────────────────────────────────────────────────── - let centersSvg = '' - for (const k of CENTER_ORDER) { - const c = CENTERS_GEOM[k] - const defined = isDefined(k) - centersSvg += `` - } - - // ── G-center face ────────────────────────────────────────────────────────────── - const faceSvg = ` - - - - - - - ` - - // ── Gate circles ─────────────────────────────────────────────────────────────── - let gatesSvg = '' - for (const k of CENTER_ORDER) { - const c = CENTERS_GEOM[k] - for (const [numStr, [x, y]] of Object.entries(c.gateAnchors)) { - const gateNum = Number(numStr) - const state = act[gateNum] - const fill = actFill(state) - const isAct = !!fill - const bgFill = fill === 'both' ? ACT_UNCONSCIOUS : (fill ?? HD_PALETTE.paper) - const textCol = isAct ? '#ffffff' : HD_PALETTE.ink - - gatesSvg += ` - ` - if (fill === 'both') { - // inner black stripe for consciousness - gatesSvg += `` - } - gatesSvg += ` - ${numStr}` - } - } - - return ` - - - - ${channelsSvg} - - ${centersSvg} - - ${faceSvg} - - ${gatesSvg} - ` -} - -// ─── HTML template ───────────────────────────────────────────────────────────── - -function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') -} - -// ─── PDF 檔名 ────────────────────────────────────────────────────────────────── - -/** 移除檔名不能用的字元(路徑分隔符、萬用字元等),避免使用者自訂名稱裡的符號弄壞檔名 */ -function sanitizeFilenamePart(s: string): string { - const cleaned = s.trim().replace(/[\\/:*?"<>|]+/g, '_') - return cleaned || '未命名' -} - -function timestampForFilename(): string { - const d = new Date() - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` -} - -/** 檔名格式:圖表種類-圖表名稱-下載時間 */ -function buildPdfFilename(kind: string, name: string | null | undefined): string { - return `${kind}-${sanitizeFilenamePart(name || '未命名')}-${timestampForFilename()}.pdf` -} - -/** expo-print 產生的檔案在快取目錄裡是隨機檔名,這裡複製一份成使用者看得懂的檔名再分享 */ -async function shareGeneratedPdf(sourceUri: string, filename: string, dialogTitle: string): Promise { - const source = new File(sourceUri) - const dest = new File(Paths.cache, filename) - await source.copy(dest, { overwrite: true }) - - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(dest.uri, { - mimeType: 'application/pdf', - dialogTitle, - UTI: 'com.adobe.pdf', - }) - } else { - const { Alert } = await import('react-native') - Alert.alert('PDF 已產生', `報告已儲存至:${dest.uri}`) - } -} - -// 以下版面小工具跨三種報告(個人/合圖/流日)共用同一份實作,避免各自維護造成內容/樣式drift -// (這正是這幾輪修正一直在處理的問題——只改一處,三種報告都會跟著對齊) - -function row(label: string, value: string, accent = false, dim = false): string { - return `
${label}${value}
` -} - -function tags(items: string[]): string { - return `
${items.map(i => `${i}`).join('')}
` -} - -// 中心/通道都需要區分「已定義(active)」與「未定義」樣式,跟畫面上 Tag 元件的 active 狀態對齊 -function stateTags(items: { label: string; active: boolean }[]): string { - return `
${items.map(i => `${i.label}`).join('')}
` -} - -function gateTags(gates: number[]): string { - return `
${[...gates].sort((a, b) => a - b).map(g => `${g}`).join('')}
` -} - -function section(title: string, body: string): string { - return `
${title}
${body}
` -} - -/** 九大中心:跟畫面(preview.tsx / [id].tsx / CompositeInfo.tsx / TransitAnalysis.tsx)算法一致, - * 全部 9 個中心都列出、用中文名稱,並標示是否已定義 */ -function centerTagsFor(rawCenterIds: string[]): string { - const defined = new Set(rawCenterIds.map(normalizeCenterId)) - return stateTags(CENTER_ORDER.map(k => ({ - label: HD_CENTERS_INFO[k]?.name.zh ?? k, - active: defined.has(k), - }))) -} - -/** 定義通道:畫面上一律以 active(強調色)樣式呈現,並轉換成 34–20 這種可讀格式 */ -function channelTagsFor(rawChannelIds: string[]): string { - return stateTags(rawChannelIds.map(rawCh => { - const ch = findChannelById(rawCh) - return { label: ch ? `${ch.from}–${ch.to}` : rawCh, active: true } - })) -} - -function buildHtml(chart: PendingChart, mode: PdfThemeMode): string { - const { bg, ink, crimson, sub, cardBg, border, dimBg, altRow, paperDeep, accentBg, dimText } = pdfPalette(mode) - - const svgMarkup = buildBodyGraphSvg(chart) - - const typeMeta = getTypeMeta(chart.type) - const centerTags = centerTagsFor(chart.centers) - const channelTags = channelTagsFor(chart.channels) - - const crossSection = chart.incarnationCross ? section('輪迴交叉', - row('交叉類型', chart.incarnationCross.crossTypeLabel, true) + - row('交叉名稱', `${chart.incarnationCross.crossBaseName}${chart.incarnationCross.variant}`) + - row('完整名稱', `${chart.incarnationCross.crossTypeLabel}之${chart.incarnationCross.crossBaseName}${chart.incarnationCross.variant}`) + - row('閘門組合', chart.incarnationCross.gatesLabel) - ) : '' - - const arrowsSection = (chart.variables && chart.arrows) ? section('四箭頭(Variables)', - ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
方向類別項目說明
${chart.arrows.topLeft ? '←' : '→'}飲食
Digestion
Design 太陽
${chart.variables.digestion.label}${chart.variables.digestion.description}
${chart.arrows.bottomLeft ? '←' : '→'}環境
Environment
Design 北交點
${chart.variables.environment.label}${chart.variables.environment.description}
${chart.arrows.topRight ? '←' : '→'}動機
Motivation
Pers. 太陽
${chart.variables.motivation.label}${chart.variables.motivation.description}
${chart.arrows.bottomRight ? '←' : '→'}觀點
Perspective
Pers. 北交點
${chart.variables.perspective.label}${chart.variables.perspective.description}
` - ) : '' - - const planetsSection = chart.planets && chart.planets.length > 0 ? section('行星閘門對照', - ` - - - - - - - ${chart.planets.map((p, i) => - ` - - - - ` - ).join('')} - -
行星● 意識(黑)● 潛意識(紅)
${p.name}${p.blackGate}.${p.blackLine}${p.redGate}.${p.redLine}
` - ) : '' - - return ` - - - - - - - -

${chart.name || '人類圖本命盤'}

-

${chart.birthDate} ${chart.birthTime} ${chart.birthCity}${chart.timezone ? ` ${chart.timezone}` : ''}

- - -
-
Body Graph
-
- 意識(黑) - 潛意識(紅) -
-
${svgMarkup}
-
- - - ${section('類型', - row('能量類型', getTypeLabel(chart.type), true) + - row('策略', typeMeta.strategy) + - row('簽名(成功徵兆)', typeMeta.signature, true) + - row('非自我主題', typeMeta.notSelf, false, true) - )} - - - ${section('設計', - row('內在權威', chart.authority, true) + - row('人生角色(Profile)', chart.profile) + - row('定義', chart.definition) - )} - - - ${section('九大中心', centerTags)} - - - ${chart.channels.length > 0 ? section(`定義通道(${chart.channels.length})`, channelTags) : ''} - - - ${planetsSection} - - - ${crossSection} - - - ${arrowsSection} - - - ${section(`激活閘門(${chart.gates.length})`, gateTags(chart.gates))} - - - -` -} - -// ─── Public exports ──────────────────────────────────────────────────────────── - -export async function downloadChartAsPdf(chart: PendingChart, mode: PdfThemeMode = 'light'): Promise { - const html = buildHtml(chart, mode) - const { uri } = await Print.printToFileAsync({ html, base64: false }) - const filename = buildPdfFilename('個人', chart.name) - await shareGeneratedPdf(uri, filename, '儲存或分享人類圖報告') -} - -export function generateAiPrompt(chart: PendingChart): string { - const definedCenterNames = chart.centers.map(id => CENTER_NAME[id] ?? id) - const definedSet = new Set(chart.centers.map(normalizeCenterAlias)) - const openCenterNames = ALL_CENTER_IDS.filter(id => !definedSet.has(id)).map(id => CENTER_NAME[id] ?? id) - - const channelsStr = chart.channels.length > 0 - ? chart.channels.map(rawId => { - const ch = findChannelById(rawId) - return ch ? `${rawId}(${ch.name.zh})` : rawId - }).join('、') - : '無' - - const planetRows = (chart.planets && chart.planets.length > 0) - ? chart.planets.map(p => ` ${p.name}:Personality ${p.blackGate}.${p.blackLine} / Design ${p.redGate}.${p.redLine}`).join('\n') - : ' 無' - - const crossLine = chart.incarnationCross - ? `${chart.incarnationCross.crossTypeLabel}之${chart.incarnationCross.crossName}(${chart.incarnationCross.gatesLabel})` - : '—' - - const variablesSection = chart.variables ? ` -【四箭頭 Variables】 -飲食方式(Digestion):${chart.variables.digestion.label} — ${chart.variables.digestion.description} -適合環境(Environment):${chart.variables.environment.label} — ${chart.variables.environment.description} -觀點(Perspective):${chart.variables.perspective.label} — ${chart.variables.perspective.description} -思考動機(Motivation):${chart.variables.motivation.label} — ${chart.variables.motivation.description}` : '' - - const authTip = AUTHORITY_TIP[chart.authority] ?? '' - - return `以下是我的 Human Design(人類圖)資料,請根據這些資料為我進行深度解讀。 -請只根據我提供的資料分析,不要自行推算或補充未提供的閘門、爻線與通道。 -若發現資料之間有矛盾,請直接指出,不要強行解釋。 - -姓名/圖表名稱:${chart.name || '(未命名)'} -出生資料:${chart.birthDate} ${chart.birthTime},${chart.birthCity} - -【類型 Type】 -${chart.type} -策略:${STRATEGY_MAP[chart.type] ?? '—'} -正向標誌:${SIGNATURE_MAP[chart.type]?.positive ?? '—'} / 負向標誌:${SIGNATURE_MAP[chart.type]?.negative ?? '—'} - -【人生角色 Profile】 -${chart.profile} - -【決策權威 Authority】 -${chart.authority}${authTip ? '\n' + authTip : ''} - -【定義 Definition】 -${chart.definition} -已定義 ${chart.centers.length} / 9 中心,激活 ${chart.gates.length} 閘門 - -【輪迴交叉 Incarnation Cross】 -${crossLine} -${variablesSection} - -【已定義能量中心】 -${definedCenterNames.join('、') || '無'} - -【開放能量中心】 -${openCenterNames.join('、') || '無'} - -【已定義通道 Defined Channels】 -${channelsStr} - -【行星閘門 Planetary Gates】 -${planetRows} - -請依照以下結構解讀: - -1. 核心設計總覽: - 用一段話說明我這張圖的整體主軸(類型 × 人生角色 × 輪迴交叉 - 如何構成我的人生方向)。 - -2. 類型與策略: - ${chart.type}「${STRATEGY_MAP[chart.type] ?? '—'}」在我的圖中具體是什麼樣子? - 哪些領域的邀請對我特別重要?${SIGNATURE_MAP[chart.type]?.negative ?? '負向標誌'}感通常會從哪裡冒出來? - -3. 決策權威實際操作: - ${chart.authority}在日常中如何辨識?請給出「這是真正的權威訊號」vs - 「這是頭腦假裝的訊號」的具體分辨方法, - 並結合我開放的能量中心,說明我容易被什麼帶偏。 - -4. ${chart.definition}的課題: - 請說明我的定義中心之間的關係、是否存在缺口與橋接閘門, - 以及這會如何影響我對特定人事物的依賴。 - -5. 人生角色 ${chart.profile} 的運作方式: - 兩條爻線分別代表的行為模式如何搭配? - 這對我理解自己的成長歷程有什麼意義? - -6. 通道與重要閘門: - 逐一解讀我已定義的通道(${channelsStr})的天賦與陰影面。 - 行星閘門請聚焦在太陽/地球軸,其餘行星挑對主題有顯著影響的講即可,不必逐一羅列。 - -7. 開放中心的制約課題: - 在我開放的中心中,挑出對我影響最大的 2-3 個, - 深入說明「非自己」的行為長什麼樣子, - 以及我可以用什麼問句自我檢查。 - -8. 四箭頭的生活應用: - ${chart.variables ? `${chart.variables.digestion.label}飲食、${chart.variables.environment.label}環境、${chart.variables.perspective.label}觀點、${chart.variables.motivation.label}動機, - 分別給出一個具體可執行的生活調整。` : '(無四箭頭資料,可略過此項)'} - -9. 總結: - 給我 3-5 點依重要性排序的實際建議, - 每一點都要對應到前面的分析,不要泛泛而談。` -} - -// ─── Composite / Transit prompt ─────────────────────────────────────────────── - -const stripCenterSuffix = (name: string): string => name.replace('中心', '') - -export function generateCompositeAiPrompt(result: CreateCompositeResult): string { - const allConns = [...result.electromagnetic, ...result.companionship, ...result.compromise, ...result.dominance] - - // 從四類連結中反推每個人自己完整定義(兩個閘門都有)的通道與中心 - const personSummary = (side: 'a' | 'b', type: string, profile: string, authority: string) => { - const gatesKey = side === 'a' ? 'aGates' : 'bGates' - const ownChannels = allConns.filter(c => c[gatesKey].length === 2) - const centerIds = new Set() - ownChannels.forEach(c => { centerIds.add(c.centerA); centerIds.add(c.centerB) }) - const centers = [...centerIds].map(id => stripCenterSuffix(CENTER_NAME[id] ?? id)).join('、') || '無' - const channels = ownChannels.map(c => c.channelId).join('、') || '無' - return `能量類型:${type} -人生角色:${profile} -決策權威:${authority} -已定義中心:${centers} -已定義通道:${channels}` - } - - const openCenterIds = ALL_CENTER_IDS.filter(id => !result.compositeDefinedCenterIds.includes(id)) - const openLabel = openCenterIds.length > 0 - ? '開放' + openCenterIds.map(id => stripCenterSuffix(CENTER_NAME[id] ?? id)).join('、') - : '無開放中心' - - const resonanceLabel = result.profileResonance.length > 0 - ? `有(${result.profileResonance.join('、')} 爻)` - : '無' - - const fmtConn = (c: ConnectionDynamic) => - `${c.channelId}(${stripCenterSuffix(CENTER_NAME[c.centerA] ?? c.centerA)}—${stripCenterSuffix(CENTER_NAME[c.centerB] ?? c.centerB)})` - - const fmtList = (list: ConnectionDynamic[]) => list.length > 0 ? list.map(fmtConn).join('、') : '無' - - // 妥協連結:擁有完整通道(兩個閘門)的一方是妥協方 - const fmtCompromiseList = (list: ConnectionDynamic[]) => - list.length > 0 ? list.map(c => `${fmtConn(c)},${c.aGates.length === 2 ? 'A' : 'B'}方妥協`).join(';') : '無' - - // 支配連結:擁有閘門的一方(另一方完全沒有)是支配方 - const fmtDominanceList = (list: ConnectionDynamic[]) => - list.length > 0 ? list.map(c => `${fmtConn(c)},${c.aGates.length > c.bGates.length ? 'A' : 'B'}方支配`).join(';') : '無' - - const personALabel = result.personA.name ? `${result.personA.name} 的人類圖` : 'A 的人類圖' - const personBLabel = result.personB.name ? `${result.personB.name} 的人類圖` : 'B 的人類圖' - - return `以下是兩人的 Human Design(人類圖)合圖資料,請根據這些資料進行深度的合圖關係解讀。 -請只根據我提供的資料分析,不要自行推算或補充未提供的閘門與通道。 - -【${personALabel}】 -${personSummary('a', result.personA.type, result.personA.profile, result.personA.authority)} - -【${personBLabel}】 -${personSummary('b', result.personB.type, result.personB.profile, result.personB.authority)} - -【合圖整合資料】 -定義中心整合:${result.integrationTheme}(${openLabel}) -人生角色共鳴爻線:${resonanceLabel} -電磁連結:${fmtList(result.electromagnetic)} -陪伴連結:${fmtList(result.companionship)} -妥協連結:${fmtCompromiseList(result.compromise)} -支配連結:${fmtDominanceList(result.dominance)} - -請從以下角度分析: -1. 兩人類型與策略的互動模式(含能量場的給予與接收) -2. 人生角色的共鳴、互補與潛在誤解 -3. 決策權威差異造成的相處節奏,以及如何配合彼此的決策方式 -4. 四類通道連結的動力:電磁的吸引與火花、陪伴的穩定基礎、 - 妥協與支配連結中誰讓步誰主導,可能累積什麼壓力 -5. 開放中心作為兩人共同課題的意義 -6. 整體能量場整合度評估,並給出 3-5 點具體可執行的相處建議` -} - -export function generateTransitAiPrompt(result: CreateTransitResult): string { - const planetRows = result.transit.planets - .map(p => ` ${p.planetName}:${p.gate}.${p.line}`) - .join('\n') - - const personalCenters = result.personalDefinedCenterIds.map(id => CENTER_NAME[id] ?? id).join('、') || '無' - const personalGates = [...result.personalGates].sort((a, b) => a - b).join('、') || '無' - - const personalGateSet = new Set(result.personalGates) - const transitGateSet = new Set(result.transit.allGates) - - const sharedGates = result.transit.allGates.filter(g => personalGateSet.has(g)).sort((a, b) => a - b) - const sharedGatesLabel = sharedGates.length > 0 ? sharedGates.join('、') : '無' - - // 流日獨立形成的通道(兩個閘門都只由流日激活)與個人閘門+流日閘門合成的新通道 - const personalChannelIdSet = new Set(result.personalDefinedChannelIds) - const transitOnlyChannels: string[] = [] - const completingChannels: string[] = [] - const seenPairs = new Set() - for (const ch of HD_CHANNELS) { - const key = `${Math.min(ch.from, ch.to)}-${Math.max(ch.from, ch.to)}` - if (seenPairs.has(key)) continue - seenPairs.add(key) - if (personalChannelIdSet.has(ch.id)) continue - - const aInPersonal = personalGateSet.has(ch.from) - const bInPersonal = personalGateSet.has(ch.to) - const aInTransit = transitGateSet.has(ch.from) - const bInTransit = transitGateSet.has(ch.to) - - if (!aInPersonal && !bInPersonal && aInTransit && bInTransit) { - transitOnlyChannels.push(`${ch.from}-${ch.to}`) - } else if (aInPersonal && !bInPersonal && bInTransit) { - completingChannels.push(`${ch.from}-${ch.to}(個人 ${ch.from} + 流日 ${ch.to})`) - } else if (bInPersonal && !aInPersonal && aInTransit) { - completingChannels.push(`${ch.from}-${ch.to}(個人 ${ch.to} + 流日 ${ch.from})`) - } - } - - const combinedCenterSet = new Set(result.combined.definedCenterIds) - const openActivated = ALL_CENTER_IDS.filter( - id => !result.personalDefinedCenterIds.includes(id) && combinedCenterSet.has(id), - ) - const openActivatedLabel = openActivated.length > 0 ? openActivated.map(id => CENTER_NAME[id] ?? id).join('、') : '無' - - const transitDate = (() => { - try { - return new Date(result.transit.computedAt).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - } catch { return result.transit.computedAt } - })() - - const sunGate = result.transit.planets.find(p => p.planetName === '太陽') - const earthGate = result.transit.planets.find(p => p.planetName === '地球') - const sunEarthAxis = sunGate && earthGate ? `${sunGate.gate}-${earthGate.gate}` : '—' - - return `以下是我的個人人類圖與今日流日資料,請進行深度流日解讀。 -請只根據我提供的資料分析,不要自行推算未提供的閘門、通道或中心。 - -【個人人類圖】 -類型:${result.type ?? '—'}(${getTypeLabel(result.type)}) -人生角色:${result.profile ?? '—'} -決策權威:${result.authority ?? '—'} -已定義中心:${personalCenters} -已定義通道:${result.personalDefinedChannelIds.join('、') || '無'} -個人激活閘門:${personalGates} - -【流日時間】 -${transitDate}(台北時間) - -【流日行星閘門】 -${planetRows} - -【疊加分析資料】 -個人與流日共有閘門:${sharedGatesLabel} -流日獨立形成的通道:${transitOnlyChannels.length > 0 ? transitOnlyChannels.join('、') : '無'} -個人閘門+流日閘門合成的新通道:${completingChannels.length > 0 ? completingChannels.join('、') : '無'} -今日暫時被定義的開放中心:${openActivatedLabel} - -請分析: -1. 今日整體流日天氣:太陽/地球軸(${sunEarthAxis})的主題是什麼 -2. 暫時被定義的${openActivatedLabel}中心,我可能有什麼不熟悉的體驗?哪些感受是「借來的」,不該當成自己的? -3. 共有閘門 ${sharedGatesLabel} 被流日強化,對我的既有特質有什麼放大效果? -4. 結合我的 ${result.type ?? '—'} 策略(${STRATEGY_MAP[result.type] ?? '—'})與${result.authority ?? '—'},今天適合推進什麼、該避免什麼? -5. 給我 3-5 點今天具體可執行的建議,並註明時效(月亮閘門幾小時就會換,太陽閘門約 5-6 天)。` -} - -// ─── Composite / Transit PDF download ──────────────────────────────────────── - -function buildCompositeHtml(result: CreateCompositeResult, mode: PdfThemeMode): string { - const { bg, ink, crimson, sub, cardBg, border, dimBg, altRow, paperDeep, accentBg } = pdfPalette(mode) - const tc = mode === 'dark' ? darkColors : lightColors - - const nameA = escapeHtml(result.personA.name ?? 'A') - const nameB = escapeHtml(result.personB.name ?? 'B') - const aDate = escapeHtml(result.personA.birthDate) - const aTime = escapeHtml(result.personA.birthTime) - const aCity = escapeHtml(result.personA.birthCity) - // 跟畫面(CompositeInfo.tsx / CompositeView.tsx)一致:顯示中文標籤,不是原始英文 type 值 - const aType = escapeHtml(getTypeLabel(result.personA.type)) - const aProfile = escapeHtml(result.personA.profile) - const aAuth = escapeHtml(result.personA.authority) - const aAuthTip = result.personA.authorityTip ? escapeHtml(result.personA.authorityTip) : '' - const bDate = escapeHtml(result.personB.birthDate) - const bTime = escapeHtml(result.personB.birthTime) - const bCity = escapeHtml(result.personB.birthCity) - const bType = escapeHtml(getTypeLabel(result.personB.type)) - const bProfile = escapeHtml(result.personB.profile) - const bAuth = escapeHtml(result.personB.authority) - const bAuthTip = result.personB.authorityTip ? escapeHtml(result.personB.authorityTip) : '' - - // Bodygraph: use full allGates for each person - // Fallback for old API responses without allGates - const aAllGates = result.personA.allGates ?? [] - const bAllGates = result.personB.allGates ?? [] - const fallbackGates: number[] = [] - const useFallback = !aAllGates.length && !bAllGates.length - if (useFallback) { - for (const type of ['electromagnetic', 'companionship', 'compromise', 'dominance'] as const) { - for (const conn of result[type]) { - for (const g of conn.aGates) fallbackGates.push(g) - for (const g of conn.bGates) fallbackGates.push(g) - } - } - } - const svgChart: PendingChart = { - name: `${nameA} x ${nameB}`, - birthDate: '', birthTime: '', birthCity: '', timezone: '', - type: '', authority: '', profile: '', definition: '', - centers: result.compositeDefinedCenterIds, - channels: result.compositeDefinedChannelIds ?? [], - gates: useFallback ? [...new Set(fallbackGates)] : [...new Set([...aAllGates, ...bAllGates])], - personalityGates: useFallback ? [] : aAllGates, - designGates: useFallback ? [] : bAllGates, - } - const svgMarkup = buildBodyGraphSvg(svgChart) - - const theme = INTEGRATION_THEME[result.integrationTheme] ?? INTEGRATION_THEME['6+3+'] - - // 顏色跟著明暗主題走(畫面 connColors() 也是這樣),避免深色模式下 PDF 顯示淺色配色 - const CONN_META = { - electromagnetic: { label: CONN_LABEL.electromagnetic, color: tc.em, bg: tc.emDimBg, desc: CONN_DESC.electromagnetic }, - companionship: { label: CONN_LABEL.companionship, color: tc.comp, bg: tc.compDimBg, desc: CONN_DESC.companionship }, - compromise: { label: CONN_LABEL.compromise, color: tc.compro, bg: tc.comproDimBg, desc: CONN_DESC.compromise }, - dominance: { label: CONN_LABEL.dominance, color: tc.dom, bg: tc.domDimBg, desc: CONN_DESC.dominance }, - } as const - - // 顏色跟畫面 CompositeInfo.tsx 一致:依「意識/潛意識」區分(text/designRed), - // 不是依人物 A/B 區分——人物身分改由上方的分組標題列顯示 - const planetTable = (result.personA.planets?.length ?? 0) > 0 - ? section('行星閘門對照', - ` - - - - - - - - - - - - - - - ${(result.personA.planets ?? []).map((p, i) => { - const pb = result.personB.planets?.[i] - return ` - - - - - - ` - }).join('')} -
${nameA}${nameB}
行星意識潛意識意識潛意識
${p.name}${p.blackGate}.${p.blackLine}${p.redGate}.${p.redLine}${pb?.blackGate ?? '—'}.${pb?.blackLine ?? ''}${pb?.redGate ?? '—'}.${pb?.redLine ?? ''}
`) - : '' - - // 跟畫面一致:標題用一般文字色(不是強調色),下面接一行「合圖定義 X / 9 中心 · 開放 Y 中心」文字 - const themeSection = section('能量場整合主題', - `
-
${theme.label}
-
合圖定義 ${result.compositeDefinedCount} / 9 中心 · 開放 ${result.compositeOpenCount} 中心
-
-
-
戀愛關係

${theme.love}

-
工作夥伴

${theme.work}

-
`) - - const connSection = section('四種核心連結動力', - (['electromagnetic', 'companionship', 'compromise', 'dominance'] as const).map(type => { - const cfg = CONN_META[type] - const items = result[type] - const rows = items.length === 0 - ? `
無相關通道
` - : items.map((conn, i) => - `
-
- ${conn.channelId} - ${CENTER_NAME[conn.centerA] ?? conn.centerA}—${CENTER_NAME[conn.centerB] ?? conn.centerB} -
- ${nameA}:${conn.aGates.length ? conn.aGates.join(', ') : '—'} / ${nameB}:${conn.bGates.length ? conn.bGates.join(', ') : '—'} -
` - ).join('') - return `
-
- ${cfg.label}(${items.length}) - ${cfg.desc} -
${rows}
` - }).join('') - ) - - const definedChIds = result.compositeDefinedChannelIds ?? [] - const channelsSection = definedChIds.length > 0 - ? section(`合圖定義通道(${definedChIds.length})`, channelTagsFor(definedChIds)) - : '' - - const resonanceItems = (result.profileResonance ?? []) - .map(line => PROFILE_RESONANCE_DESC[line]) - .filter((info): info is { title: string; desc: string } => !!info) - const resonanceSection = section('人生角色共鳴', - `
- ${nameA} ${aProfile} - ${nameB} ${bProfile} -
` + (resonanceItems.length === 0 - ? `

兩人人生角色沒有共同爻線,各自的觀點框架較為不同。

` - : resonanceItems.map(info => - `
${info.title}${info.desc}
` - ).join('') - ) - ) - - const authoritySection = section('策略與內在權威', - `
-
-
${nameA} 的權威
-
${aAuth}
- ${aAuthTip ? `

${aAuthTip}

` : ''} -
-
-
${nameB} 的權威
-
${bAuth}
- ${bAuthTip ? `

${bAuthTip}

` : ''} -
-
`) - - return ` - - - - - - - -

${nameA} x ${nameB} 合圖

- -
-
-
${nameA}
-
${aDate} · ${aTime}
-
${aCity}
-
${aType} · ${aProfile}
-
-
-
${nameB}
-
${bDate} · ${bTime}
-
${bCity}
-
${bType} · ${bProfile}
-
-
- -
-
合圖 Body Graph
-
- ${nameA}(黑) - ${nameB}(紅) -
-
${svgMarkup}
-
- - ${planetTable} - ${themeSection} - ${connSection} - ${channelsSection} - ${resonanceSection} - ${authoritySection} - - - -` -} - -export async function downloadCompositePdf(result: CreateCompositeResult, mode: PdfThemeMode = 'light', chartName?: string | null): Promise { - const html = buildCompositeHtml(result, mode) - const { uri } = await Print.printToFileAsync({ html, base64: false }) - const fallbackName = `${result.personA.name ?? 'A'} x ${result.personB.name ?? 'B'}` - const filename = buildPdfFilename('合圖', chartName || fallbackName) - await shareGeneratedPdf(uri, filename, '儲存或分享合圖分析報告') -} - -function buildTransitHtml(result: CreateTransitResult, mode: PdfThemeMode): string { - const { bg, ink, crimson, sub, cardBg, border, dimBg, altRow, paperDeep, accentBg } = pdfPalette(mode) - const tc = mode === 'dark' ? darkColors : lightColors - - // 流日圖的黑/紅語意跟個人圖不同:黑=個人(personality+design 都算),紅=今日流日, - // 兩者都激活時疊成條紋——這裡要跟畫面共用的 buildTransitBodyGraphProps() 算法完全一致, - // 不能沿用 buildActivations() 預設的 personality/design 判斷法(那是給個人圖用的) - const activations: Record = {} - for (const g of result.personalityGates) activations[g] = { ...activations[g], c: true } - for (const g of result.designGates) activations[g] = { ...activations[g], c: true } - for (const g of result.transit.allGates) activations[g] = { ...activations[g], u: true } - - const svgChart: PendingChart = { - name: '流日', - birthDate: '', birthTime: '', birthCity: '', timezone: '', - type: '', authority: '', profile: '', definition: '', - centers: result.combined.definedCenterIds, - channels: result.combined.definedChannelIds, - gates: [], - } - const svgMarkup = buildBodyGraphSvg(svgChart, activations) - - const now = new Date() - const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}` - - // Format transit date - const transitDate = result.transit.computedAt - ? new Date(result.transit.computedAt).toLocaleString('zh-TW', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : ts - - // 今日行星閘門:跟畫面(TransitView.tsx)一致,個人的潛意識/意識 + 今日流日三欄並排比較 - const transitPlanetsTable = result.transit.planets.length > 0 - ? section('今日行星閘門', - ` - - - - - - - - ${result.transit.planets.map((tp, i) => { - const pp = result.personalPlanets?.find(p => p.planetName === tp.planetName) - return ` - - - - - ` - }).join('')} - -
行星潛意識意識流日
${tp.planetName}${pp ? `${pp.design.gate}.${pp.design.line}` : '—'}${pp ? `${pp.personality.gate}.${pp.personality.line}` : '—'}${tp.gate}.${tp.line}
`) - : '' - - // 流日影響:跟畫面(TransitView.tsx / TransitAnalysis.tsx 共用的 ImpactCard)一致, - // 依 kind 分組成卡片(標籤 chips + 固定說明文案),不是每個 layer 各自一行 label/detail - const impactKinds = ['center-activated', 'new-channel', 'completing-channel'] as const - const impactColor: Record = { - 'center-activated': tc.em, - 'new-channel': tc.transit, - 'completing-channel': tc.compro, - } - const impactCards = impactKinds.map(kind => { - const items = result.impact.layers.filter(l => l.kind === kind) - if (items.length === 0) return '' - const color = impactColor[kind] - return `
-
${IMPACT_LABEL[kind]}
-
${items.map(l => `${l.label}`).join('')}
-

${IMPACT_DESC[kind]}

-
` - }).join('') - - const impactSection = result.impact.layers.length > 0 - ? section('流日影響分析', impactCards) - : section('流日影響分析', `
今日流日對此圖表影響不顯著
`) - - const personalGateSet = new Set(result.personalGates) - const transitOnlyGates = result.transit.allGates.filter(g => !personalGateSet.has(g)) - - return ` - - - - - - - -

個人圖 × 今日流日分析

-

流日時間:${transitDate}

- - -
-
個人 + 流日 Body Graph
-
- 個人 - 流日 - 共有 -
-
${svgMarkup}
-
- - - ${transitPlanetsTable} - - - ${impactSection} - - - ${section(`合成已定義中心(${result.combined.definedCenterIds.length})`, centerTagsFor(result.combined.definedCenterIds))} - - - ${result.combined.definedChannelIds.length > 0 ? section(`合成定義通道(${result.combined.definedChannelIds.length})`, channelTagsFor(result.combined.definedChannelIds)) : ''} - - - ${result.personalGates.length > 0 ? section(`個人激活閘門(${result.personalGates.length})`, gateTags(result.personalGates)) : ''} - - - ${transitOnlyGates.length > 0 ? section(`今日流日新增閘門(${transitOnlyGates.length})`, gateTags(transitOnlyGates)) : ''} - - - -` -} - -export async function downloadTransitPdf(result: CreateTransitResult, mode: PdfThemeMode = 'light', chartName?: string | null): Promise { - const html = buildTransitHtml(result, mode) - const { uri } = await Print.printToFileAsync({ html, base64: false }) - const filename = buildPdfFilename('流日', chartName) - await shareGeneratedPdf(uri, filename, '儲存或分享流日分析報告') -} diff --git a/mobile/lib/pdf/actions.ts b/mobile/lib/pdf/actions.ts new file mode 100644 index 0000000..80aa918 --- /dev/null +++ b/mobile/lib/pdf/actions.ts @@ -0,0 +1,50 @@ +import * as Print from 'expo-print' +import * as Sharing from 'expo-sharing' +import { File, Paths } from 'expo-file-system' +import type { PendingChart } from '@/lib/pendingChart' +import type { CreateCompositeResult, CreateTransitResult } from '@/lib/api' +import { buildPersonalChartHtml } from './personalChartHtml' +import { buildCompositeChartHtml } from './compositeHtml' +import { buildTransitChartHtml } from './transitHtml' +import { buildPdfFilename } from './htmlHelpers' +import type { PdfThemeMode } from './types' + +/** expo-print 產生的檔案在快取目錄裡是隨機檔名,這裡複製一份成使用者看得懂的檔名再分享 */ +async function shareGeneratedPdf(sourceUri: string, filename: string, dialogTitle: string): Promise { + const source = new File(sourceUri) + const dest = new File(Paths.cache, filename) + await source.copy(dest, { overwrite: true }) + + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(dest.uri, { + mimeType: 'application/pdf', + dialogTitle, + UTI: 'com.adobe.pdf', + }) + } else { + const { Alert } = await import('react-native') + Alert.alert('PDF 已產生', `報告已儲存至:${dest.uri}`) + } +} + +export async function downloadChartAsPdf(chart: PendingChart, mode: PdfThemeMode = 'light'): Promise { + const html = buildPersonalChartHtml(chart, mode) + const { uri } = await Print.printToFileAsync({ html, base64: false }) + const filename = buildPdfFilename('個人', chart.name) + await shareGeneratedPdf(uri, filename, '儲存或分享人類圖報告') +} + +export async function downloadCompositePdf(result: CreateCompositeResult, mode: PdfThemeMode = 'light', chartName?: string | null): Promise { + const html = buildCompositeChartHtml(result, mode) + const { uri } = await Print.printToFileAsync({ html, base64: false }) + const fallbackName = `${result.personA.name ?? 'A'} x ${result.personB.name ?? 'B'}` + const filename = buildPdfFilename('合圖', chartName || fallbackName) + await shareGeneratedPdf(uri, filename, '儲存或分享合圖分析報告') +} + +export async function downloadTransitPdf(result: CreateTransitResult, mode: PdfThemeMode = 'light', chartName?: string | null): Promise { + const html = buildTransitChartHtml(result, mode) + const { uri } = await Print.printToFileAsync({ html, base64: false }) + const filename = buildPdfFilename('流日', chartName) + await shareGeneratedPdf(uri, filename, '儲存或分享流日分析報告') +} diff --git a/mobile/lib/pdf/aiPrompts.ts b/mobile/lib/pdf/aiPrompts.ts new file mode 100644 index 0000000..bef43c4 --- /dev/null +++ b/mobile/lib/pdf/aiPrompts.ts @@ -0,0 +1,281 @@ +import { HD_CHANNELS } from '@shared/humanDesign/hd-chart-data' +import { findChannelById } from '@/lib/hd-normalizers' +import { + STRATEGY_MAP, + SIGNATURE_MAP, + AUTHORITY_TIP, + CENTER_NAME, + ALL_CENTER_IDS, + normalizeCenterAlias, +} from '@/lib/hd-constants' +import { getTypeLabel } from '@/lib/hd-type-meta' +import type { PendingChart } from '@/lib/pendingChart' +import type { CreateCompositeResult, CreateTransitResult, ConnectionDynamic } from '@/lib/api' + +export function generateAiPrompt(chart: PendingChart): string { + const definedCenterNames = chart.centers.map(id => CENTER_NAME[id] ?? id) + const definedSet = new Set(chart.centers.map(normalizeCenterAlias)) + const openCenterNames = ALL_CENTER_IDS.filter(id => !definedSet.has(id)).map(id => CENTER_NAME[id] ?? id) + + const channelsStr = chart.channels.length > 0 + ? chart.channels.map(rawId => { + const ch = findChannelById(rawId) + return ch ? `${rawId}(${ch.name.zh})` : rawId + }).join('、') + : '無' + + const planetRows = (chart.planets && chart.planets.length > 0) + ? chart.planets.map(p => ` ${p.name}:Personality ${p.blackGate}.${p.blackLine} / Design ${p.redGate}.${p.redLine}`).join('\n') + : ' 無' + + const crossLine = chart.incarnationCross + ? `${chart.incarnationCross.crossTypeLabel}之${chart.incarnationCross.crossName}(${chart.incarnationCross.gatesLabel})` + : '—' + + const variablesSection = chart.variables ? ` +【四箭頭 Variables】 +飲食方式(Digestion):${chart.variables.digestion.label} — ${chart.variables.digestion.description} +適合環境(Environment):${chart.variables.environment.label} — ${chart.variables.environment.description} +觀點(Perspective):${chart.variables.perspective.label} — ${chart.variables.perspective.description} +思考動機(Motivation):${chart.variables.motivation.label} — ${chart.variables.motivation.description}` : '' + + const authTip = AUTHORITY_TIP[chart.authority] ?? '' + + return `以下是我的 Human Design(人類圖)資料,請根據這些資料為我進行深度解讀。 +請只根據我提供的資料分析,不要自行推算或補充未提供的閘門、爻線與通道。 +若發現資料之間有矛盾,請直接指出,不要強行解釋。 + +姓名/圖表名稱:${chart.name || '(未命名)'} +出生資料:${chart.birthDate} ${chart.birthTime},${chart.birthCity} + +【類型 Type】 +${chart.type} +策略:${STRATEGY_MAP[chart.type] ?? '—'} +正向標誌:${SIGNATURE_MAP[chart.type]?.positive ?? '—'} / 負向標誌:${SIGNATURE_MAP[chart.type]?.negative ?? '—'} + +【人生角色 Profile】 +${chart.profile} + +【決策權威 Authority】 +${chart.authority}${authTip ? '\n' + authTip : ''} + +【定義 Definition】 +${chart.definition} +已定義 ${chart.centers.length} / 9 中心,激活 ${chart.gates.length} 閘門 + +【輪迴交叉 Incarnation Cross】 +${crossLine} +${variablesSection} + +【已定義能量中心】 +${definedCenterNames.join('、') || '無'} + +【開放能量中心】 +${openCenterNames.join('、') || '無'} + +【已定義通道 Defined Channels】 +${channelsStr} + +【行星閘門 Planetary Gates】 +${planetRows} + +請依照以下結構解讀: + +1. 核心設計總覽: + 用一段話說明我這張圖的整體主軸(類型 × 人生角色 × 輪迴交叉 + 如何構成我的人生方向)。 + +2. 類型與策略: + ${chart.type}「${STRATEGY_MAP[chart.type] ?? '—'}」在我的圖中具體是什麼樣子? + 哪些領域的邀請對我特別重要?${SIGNATURE_MAP[chart.type]?.negative ?? '負向標誌'}感通常會從哪裡冒出來? + +3. 決策權威實際操作: + ${chart.authority}在日常中如何辨識?請給出「這是真正的權威訊號」vs + 「這是頭腦假裝的訊號」的具體分辨方法, + 並結合我開放的能量中心,說明我容易被什麼帶偏。 + +4. ${chart.definition}的課題: + 請說明我的定義中心之間的關係、是否存在缺口與橋接閘門, + 以及這會如何影響我對特定人事物的依賴。 + +5. 人生角色 ${chart.profile} 的運作方式: + 兩條爻線分別代表的行為模式如何搭配? + 這對我理解自己的成長歷程有什麼意義? + +6. 通道與重要閘門: + 逐一解讀我已定義的通道(${channelsStr})的天賦與陰影面。 + 行星閘門請聚焦在太陽/地球軸,其餘行星挑對主題有顯著影響的講即可,不必逐一羅列。 + +7. 開放中心的制約課題: + 在我開放的中心中,挑出對我影響最大的 2-3 個, + 深入說明「非自己」的行為長什麼樣子, + 以及我可以用什麼問句自我檢查。 + +8. 四箭頭的生活應用: + ${chart.variables ? `${chart.variables.digestion.label}飲食、${chart.variables.environment.label}環境、${chart.variables.perspective.label}觀點、${chart.variables.motivation.label}動機, + 分別給出一個具體可執行的生活調整。` : '(無四箭頭資料,可略過此項)'} + +9. 總結: + 給我 3-5 點依重要性排序的實際建議, + 每一點都要對應到前面的分析,不要泛泛而談。` +} + +// ─── Composite / Transit prompt ─────────────────────────────────────────────── + +const stripCenterSuffix = (name: string): string => name.replace('中心', '') + +export function generateCompositeAiPrompt(result: CreateCompositeResult): string { + const allConns = [...result.electromagnetic, ...result.companionship, ...result.compromise, ...result.dominance] + + // 從四類連結中反推每個人自己完整定義(兩個閘門都有)的通道與中心 + const personSummary = (side: 'a' | 'b', type: string, profile: string, authority: string) => { + const gatesKey = side === 'a' ? 'aGates' : 'bGates' + const ownChannels = allConns.filter(c => c[gatesKey].length === 2) + const centerIds = new Set() + ownChannels.forEach(c => { centerIds.add(c.centerA); centerIds.add(c.centerB) }) + const centers = [...centerIds].map(id => stripCenterSuffix(CENTER_NAME[id] ?? id)).join('、') || '無' + const channels = ownChannels.map(c => c.channelId).join('、') || '無' + return `能量類型:${type} +人生角色:${profile} +決策權威:${authority} +已定義中心:${centers} +已定義通道:${channels}` + } + + const openCenterIds = ALL_CENTER_IDS.filter(id => !result.compositeDefinedCenterIds.includes(id)) + const openLabel = openCenterIds.length > 0 + ? '開放' + openCenterIds.map(id => stripCenterSuffix(CENTER_NAME[id] ?? id)).join('、') + : '無開放中心' + + const resonanceLabel = result.profileResonance.length > 0 + ? `有(${result.profileResonance.join('、')} 爻)` + : '無' + + const fmtConn = (c: ConnectionDynamic) => + `${c.channelId}(${stripCenterSuffix(CENTER_NAME[c.centerA] ?? c.centerA)}—${stripCenterSuffix(CENTER_NAME[c.centerB] ?? c.centerB)})` + + const fmtList = (list: ConnectionDynamic[]) => list.length > 0 ? list.map(fmtConn).join('、') : '無' + + // 妥協連結:擁有完整通道(兩個閘門)的一方是妥協方 + const fmtCompromiseList = (list: ConnectionDynamic[]) => + list.length > 0 ? list.map(c => `${fmtConn(c)},${c.aGates.length === 2 ? 'A' : 'B'}方妥協`).join(';') : '無' + + // 支配連結:擁有閘門的一方(另一方完全沒有)是支配方 + const fmtDominanceList = (list: ConnectionDynamic[]) => + list.length > 0 ? list.map(c => `${fmtConn(c)},${c.aGates.length > c.bGates.length ? 'A' : 'B'}方支配`).join(';') : '無' + + const personALabel = result.personA.name ? `${result.personA.name} 的人類圖` : 'A 的人類圖' + const personBLabel = result.personB.name ? `${result.personB.name} 的人類圖` : 'B 的人類圖' + + return `以下是兩人的 Human Design(人類圖)合圖資料,請根據這些資料進行深度的合圖關係解讀。 +請只根據我提供的資料分析,不要自行推算或補充未提供的閘門與通道。 + +【${personALabel}】 +${personSummary('a', result.personA.type, result.personA.profile, result.personA.authority)} + +【${personBLabel}】 +${personSummary('b', result.personB.type, result.personB.profile, result.personB.authority)} + +【合圖整合資料】 +定義中心整合:${result.integrationTheme}(${openLabel}) +人生角色共鳴爻線:${resonanceLabel} +電磁連結:${fmtList(result.electromagnetic)} +陪伴連結:${fmtList(result.companionship)} +妥協連結:${fmtCompromiseList(result.compromise)} +支配連結:${fmtDominanceList(result.dominance)} + +請從以下角度分析: +1. 兩人類型與策略的互動模式(含能量場的給予與接收) +2. 人生角色的共鳴、互補與潛在誤解 +3. 決策權威差異造成的相處節奏,以及如何配合彼此的決策方式 +4. 四類通道連結的動力:電磁的吸引與火花、陪伴的穩定基礎、 + 妥協與支配連結中誰讓步誰主導,可能累積什麼壓力 +5. 開放中心作為兩人共同課題的意義 +6. 整體能量場整合度評估,並給出 3-5 點具體可執行的相處建議` +} + +export function generateTransitAiPrompt(result: CreateTransitResult): string { + const planetRows = result.transit.planets + .map(p => ` ${p.planetName}:${p.gate}.${p.line}`) + .join('\n') + + const personalCenters = result.personalDefinedCenterIds.map(id => CENTER_NAME[id] ?? id).join('、') || '無' + const personalGates = [...result.personalGates].sort((a, b) => a - b).join('、') || '無' + + const personalGateSet = new Set(result.personalGates) + const transitGateSet = new Set(result.transit.allGates) + + const sharedGates = result.transit.allGates.filter(g => personalGateSet.has(g)).sort((a, b) => a - b) + const sharedGatesLabel = sharedGates.length > 0 ? sharedGates.join('、') : '無' + + // 流日獨立形成的通道(兩個閘門都只由流日激活)與個人閘門+流日閘門合成的新通道 + const personalChannelIdSet = new Set(result.personalDefinedChannelIds) + const transitOnlyChannels: string[] = [] + const completingChannels: string[] = [] + const seenPairs = new Set() + for (const ch of HD_CHANNELS) { + const key = `${Math.min(ch.from, ch.to)}-${Math.max(ch.from, ch.to)}` + if (seenPairs.has(key)) continue + seenPairs.add(key) + if (personalChannelIdSet.has(ch.id)) continue + + const aInPersonal = personalGateSet.has(ch.from) + const bInPersonal = personalGateSet.has(ch.to) + const aInTransit = transitGateSet.has(ch.from) + const bInTransit = transitGateSet.has(ch.to) + + if (!aInPersonal && !bInPersonal && aInTransit && bInTransit) { + transitOnlyChannels.push(`${ch.from}-${ch.to}`) + } else if (aInPersonal && !bInPersonal && bInTransit) { + completingChannels.push(`${ch.from}-${ch.to}(個人 ${ch.from} + 流日 ${ch.to})`) + } else if (bInPersonal && !aInPersonal && aInTransit) { + completingChannels.push(`${ch.from}-${ch.to}(個人 ${ch.to} + 流日 ${ch.from})`) + } + } + + const combinedCenterSet = new Set(result.combined.definedCenterIds) + const openActivated = ALL_CENTER_IDS.filter( + id => !result.personalDefinedCenterIds.includes(id) && combinedCenterSet.has(id), + ) + const openActivatedLabel = openActivated.length > 0 ? openActivated.map(id => CENTER_NAME[id] ?? id).join('、') : '無' + + const transitDate = (() => { + try { + return new Date(result.transit.computedAt).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + } catch { return result.transit.computedAt } + })() + + const sunGate = result.transit.planets.find(p => p.planetName === '太陽') + const earthGate = result.transit.planets.find(p => p.planetName === '地球') + const sunEarthAxis = sunGate && earthGate ? `${sunGate.gate}-${earthGate.gate}` : '—' + + return `以下是我的個人人類圖與今日流日資料,請進行深度流日解讀。 +請只根據我提供的資料分析,不要自行推算未提供的閘門、通道或中心。 + +【個人人類圖】 +類型:${result.type ?? '—'}(${getTypeLabel(result.type)}) +人生角色:${result.profile ?? '—'} +決策權威:${result.authority ?? '—'} +已定義中心:${personalCenters} +已定義通道:${result.personalDefinedChannelIds.join('、') || '無'} +個人激活閘門:${personalGates} + +【流日時間】 +${transitDate}(台北時間) + +【流日行星閘門】 +${planetRows} + +【疊加分析資料】 +個人與流日共有閘門:${sharedGatesLabel} +流日獨立形成的通道:${transitOnlyChannels.length > 0 ? transitOnlyChannels.join('、') : '無'} +個人閘門+流日閘門合成的新通道:${completingChannels.length > 0 ? completingChannels.join('、') : '無'} +今日暫時被定義的開放中心:${openActivatedLabel} + +請分析: +1. 今日整體流日天氣:太陽/地球軸(${sunEarthAxis})的主題是什麼 +2. 暫時被定義的${openActivatedLabel}中心,我可能有什麼不熟悉的體驗?哪些感受是「借來的」,不該當成自己的? +3. 共有閘門 ${sharedGatesLabel} 被流日強化,對我的既有特質有什麼放大效果? +4. 結合我的 ${result.type ?? '—'} 策略(${STRATEGY_MAP[result.type] ?? '—'})與${result.authority ?? '—'},今天適合推進什麼、該避免什麼? +5. 給我 3-5 點今天具體可執行的建議,並註明時效(月亮閘門幾小時就會換,太陽閘門約 5-6 天)。` +} diff --git a/mobile/lib/pdf/bodyGraphSvg.ts b/mobile/lib/pdf/bodyGraphSvg.ts new file mode 100644 index 0000000..0919112 --- /dev/null +++ b/mobile/lib/pdf/bodyGraphSvg.ts @@ -0,0 +1,206 @@ +import { + ACT_CONSCIOUS, + ACT_UNCONSCIOUS, + CENTER_ORDER, + CENTERS_GEOM, + HD_CHANNELS, + HD_PALETTE, + INTEGRATION_PAIRS, +} from '@shared/humanDesign/hd-chart-data' +import type { PendingChart } from '@/lib/pendingChart' +import type { GateActivation } from './types' + +// ─── Gate activation state ───────────────────────────────────────────────────── + +function buildActivations(chart: PendingChart): Record { + const act: Record = {} + if (chart.planets && chart.planets.length > 0) { + for (const p of chart.planets) { + act[p.blackGate] = { ...act[p.blackGate], c: true } + act[p.redGate] = { ...act[p.redGate], u: true } + } + } else { + const pg = chart.personalityGates ?? [] + const dg = chart.designGates ?? [] + if (pg.length > 0 || dg.length > 0) { + for (const g of pg) act[g] = { ...act[g], c: true } + for (const g of dg) act[g] = { ...act[g], u: true } + } else { + for (const g of chart.gates) act[g] = { c: true } + } + } + return act +} + +function actFill(state: GateActivation | undefined): string | null { + if (!state) return null + if (state.c && state.u) return 'both' + if (state.c) return ACT_CONSCIOUS + if (state.u) return ACT_UNCONSCIOUS + return null +} + +function gateLoc(num: number): [number, number] | null { + for (const c of Object.values(CENTERS_GEOM)) { + if (c.gateAnchors[num]) return c.gateAnchors[num] + } + return null +} + +function perpFoot(p: [number, number], a: [number, number], b: [number, number]): [number, number] { + const dx = b[0] - a[0], dy = b[1] - a[1] + const lenSq = dx * dx + dy * dy + const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq + return [a[0] + t * dx, a[1] + t * dy] +} + +// ─── SVG channel segment (HTML SVG equivalent of ChannelSegment) ─────────────── + +function channelSeg(x1: number, y1: number, x2: number, y2: number, fill: string | null, sw: number): string { + if (fill === 'both') { + return ` + + ` + } + if (fill) { + return `` + } + return ` + + ` +} + +// ─── Build BodyGraph SVG string ──────────────────────────────────────────────── + +/** 個人/合圖/流日三種報告共用同一份 BodyGraph SVG 產生邏輯,presetActivations 讓呼叫端可蓋掉 + * 預設的 personality/design 判斷法(流日圖的黑/紅語意是「個人 vs 今日流日」而非 Personality/Design) */ +export function buildBodyGraphSvg(chart: PendingChart, presetActivations?: Record): string { + const act = presetActivations ?? buildActivations(chart) + const definedCenterIds = new Set(chart.centers.map(s => s.toLowerCase().replace(/\s+/g, ''))) + + // Normalise center keys coming from API (e.g. "Solar Plexus" → "solar") + const CENTER_KEY_MAP: Record = { + 'head': 'head', 'crown': 'head', + 'ajna': 'ajna', 'mind': 'ajna', + 'throat': 'throat', + 'g': 'g', 'genter': 'g', 'gcenter': 'g', 'identity': 'g', + 'heart': 'heart', 'will': 'heart', 'ego': 'heart', + 'spleen': 'spleen', + 'sacral': 'sacral', + 'solar': 'solar', 'solarplexus': 'solar', 'emotionalcenter': 'solar', + 'root': 'root', + } + + function isDefined(k: string): boolean { + // direct match + if (definedCenterIds.has(k)) return true + // try normalising each element of chart.centers + for (const c of chart.centers) { + const norm = c.toLowerCase().replace(/[\s_-]/g, '') + const mapped = CENTER_KEY_MAP[norm] + if (mapped === k) return true + } + return false + } + + const SW = 10 + + // ── Channels ────────────────────────────────────────────────────────────────── + const seenPairs = new Set() + const drawnChannels = HD_CHANNELS.filter(ch => { + const key = `${Math.min(ch.from, ch.to)}-${Math.max(ch.from, ch.to)}` + if (seenPairs.has(key)) return false + seenPairs.add(key) + return true + }) + + let channelsSvg = '' + for (const ch of drawnChannels) { + const a = gateLoc(ch.from), b = gateLoc(ch.to) + if (!a || !b) continue + const pairKey = `${Math.min(ch.from, ch.to)}-${Math.max(ch.from, ch.to)}` + if (INTEGRATION_PAIRS.has(pairKey)) continue + + const aFill = actFill(act[ch.from]) + const bFill = actFill(act[ch.to]) + const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2 + channelsSvg += channelSeg(a[0], a[1], mx, my, aFill, SW) + channelsSvg += channelSeg(mx, my, b[0], b[1], bFill, SW) + } + + // Integration compound + const g20 = gateLoc(20), g57 = gateLoc(57), g10 = gateLoc(10), g34 = gateLoc(34) + if (g20 && g57 && g10 && g34) { + const foot10 = perpFoot(g10, g20, g57) + const foot34 = perpFoot(g34, g20, g57) + const fill20 = actFill(act[20]), fill57 = actFill(act[57]) + const fill10 = actFill(act[10]), fill34 = actFill(act[34]) + const tmx = (g20[0] + g57[0]) / 2, tmy = (g20[1] + g57[1]) / 2 + const s10mx = (g10[0] + foot10[0]) / 2, s10my = (g10[1] + foot10[1]) / 2 + const s34mx = (g34[0] + foot34[0]) / 2, s34my = (g34[1] + foot34[1]) / 2 + channelsSvg += channelSeg(g20[0], g20[1], tmx, tmy, fill20, SW) + channelsSvg += channelSeg(tmx, tmy, g57[0], g57[1], fill57, SW) + channelsSvg += channelSeg(g10[0], g10[1], s10mx, s10my, fill10, SW) + channelsSvg += channelSeg(s10mx, s10my, foot10[0], foot10[1], null, SW) + channelsSvg += channelSeg(g34[0], g34[1], s34mx, s34my, fill34, SW) + channelsSvg += channelSeg(s34mx, s34my, foot34[0], foot34[1], null, SW) + channelsSvg += `` + channelsSvg += `` + } + + // ── Centers ──────────────────────────────────────────────────────────────────── + let centersSvg = '' + for (const k of CENTER_ORDER) { + const c = CENTERS_GEOM[k] + const defined = isDefined(k) + centersSvg += `` + } + + // ── G-center face ────────────────────────────────────────────────────────────── + const faceSvg = ` + + + + + + + ` + + // ── Gate circles ─────────────────────────────────────────────────────────────── + let gatesSvg = '' + for (const k of CENTER_ORDER) { + const c = CENTERS_GEOM[k] + for (const [numStr, [x, y]] of Object.entries(c.gateAnchors)) { + const gateNum = Number(numStr) + const state = act[gateNum] + const fill = actFill(state) + const isAct = !!fill + const bgFill = fill === 'both' ? ACT_UNCONSCIOUS : (fill ?? HD_PALETTE.paper) + const textCol = isAct ? '#ffffff' : HD_PALETTE.ink + + gatesSvg += ` + ` + if (fill === 'both') { + // inner black stripe for consciousness + gatesSvg += `` + } + gatesSvg += ` + ${numStr}` + } + } + + return ` + + + + ${channelsSvg} + + ${centersSvg} + + ${faceSvg} + + ${gatesSvg} + ` +} diff --git a/mobile/lib/pdf/compositeHtml.ts b/mobile/lib/pdf/compositeHtml.ts new file mode 100644 index 0000000..bf4aa15 --- /dev/null +++ b/mobile/lib/pdf/compositeHtml.ts @@ -0,0 +1,267 @@ +import { ACT_UNCONSCIOUS } from '@shared/humanDesign/hd-chart-data' +import { lightColors, darkColors } from '@/constants/tokens' +import { getTypeLabel } from '@/lib/hd-type-meta' +import { CENTER_NAME } from '@/lib/hd-constants' +import type { CreateCompositeResult } from '@/lib/api' +import type { PendingChart } from '@/lib/pendingChart' +import { CONN_LABEL, CONN_DESC, INTEGRATION_THEME, PROFILE_RESONANCE_DESC } from '@/components/composite/compositeText' +import { pdfPalette } from './palette' +import { buildBodyGraphSvg } from './bodyGraphSvg' +import { escapeHtml, section, channelTagsFor } from './htmlHelpers' +import type { PdfThemeMode } from './types' + +export function buildCompositeChartHtml(result: CreateCompositeResult, mode: PdfThemeMode): string { + const { bg, ink, crimson, sub, cardBg, border, dimBg, altRow, paperDeep, accentBg } = pdfPalette(mode) + const tc = mode === 'dark' ? darkColors : lightColors + + const nameA = escapeHtml(result.personA.name ?? 'A') + const nameB = escapeHtml(result.personB.name ?? 'B') + const aDate = escapeHtml(result.personA.birthDate) + const aTime = escapeHtml(result.personA.birthTime) + const aCity = escapeHtml(result.personA.birthCity) + // 跟畫面(CompositeInfo.tsx / CompositeView.tsx)一致:顯示中文標籤,不是原始英文 type 值 + const aType = escapeHtml(getTypeLabel(result.personA.type)) + const aProfile = escapeHtml(result.personA.profile) + const aAuth = escapeHtml(result.personA.authority) + const aAuthTip = result.personA.authorityTip ? escapeHtml(result.personA.authorityTip) : '' + const bDate = escapeHtml(result.personB.birthDate) + const bTime = escapeHtml(result.personB.birthTime) + const bCity = escapeHtml(result.personB.birthCity) + const bType = escapeHtml(getTypeLabel(result.personB.type)) + const bProfile = escapeHtml(result.personB.profile) + const bAuth = escapeHtml(result.personB.authority) + const bAuthTip = result.personB.authorityTip ? escapeHtml(result.personB.authorityTip) : '' + + // Bodygraph: use full allGates for each person + // Fallback for old API responses without allGates + const aAllGates = result.personA.allGates ?? [] + const bAllGates = result.personB.allGates ?? [] + const fallbackGates: number[] = [] + const useFallback = !aAllGates.length && !bAllGates.length + if (useFallback) { + for (const type of ['electromagnetic', 'companionship', 'compromise', 'dominance'] as const) { + for (const conn of result[type]) { + for (const g of conn.aGates) fallbackGates.push(g) + for (const g of conn.bGates) fallbackGates.push(g) + } + } + } + const svgChart: PendingChart = { + name: `${nameA} x ${nameB}`, + birthDate: '', birthTime: '', birthCity: '', timezone: '', + type: '', authority: '', profile: '', definition: '', + centers: result.compositeDefinedCenterIds, + channels: result.compositeDefinedChannelIds ?? [], + gates: useFallback ? [...new Set(fallbackGates)] : [...new Set([...aAllGates, ...bAllGates])], + personalityGates: useFallback ? [] : aAllGates, + designGates: useFallback ? [] : bAllGates, + } + const svgMarkup = buildBodyGraphSvg(svgChart) + + const theme = INTEGRATION_THEME[result.integrationTheme] ?? INTEGRATION_THEME['6+3+'] + + // 顏色跟著明暗主題走(畫面 connColors() 也是這樣),避免深色模式下 PDF 顯示淺色配色 + const CONN_META = { + electromagnetic: { label: CONN_LABEL.electromagnetic, color: tc.em, bg: tc.emDimBg, desc: CONN_DESC.electromagnetic }, + companionship: { label: CONN_LABEL.companionship, color: tc.comp, bg: tc.compDimBg, desc: CONN_DESC.companionship }, + compromise: { label: CONN_LABEL.compromise, color: tc.compro, bg: tc.comproDimBg, desc: CONN_DESC.compromise }, + dominance: { label: CONN_LABEL.dominance, color: tc.dom, bg: tc.domDimBg, desc: CONN_DESC.dominance }, + } as const + + // 顏色跟畫面 CompositeInfo.tsx 一致:依「意識/潛意識」區分(text/designRed), + // 不是依人物 A/B 區分——人物身分改由上方的分組標題列顯示 + const planetTable = (result.personA.planets?.length ?? 0) > 0 + ? section('行星閘門對照', + ` + + + + + + + + + + + + + + + ${(result.personA.planets ?? []).map((p, i) => { + const pb = result.personB.planets?.[i] + return ` + + + + + + ` + }).join('')} +
${nameA}${nameB}
行星意識潛意識意識潛意識
${p.name}${p.blackGate}.${p.blackLine}${p.redGate}.${p.redLine}${pb?.blackGate ?? '—'}.${pb?.blackLine ?? ''}${pb?.redGate ?? '—'}.${pb?.redLine ?? ''}
`) + : '' + + // 跟畫面一致:標題用一般文字色(不是強調色),下面接一行「合圖定義 X / 9 中心 · 開放 Y 中心」文字 + const themeSection = section('能量場整合主題', + `
+
${theme.label}
+
合圖定義 ${result.compositeDefinedCount} / 9 中心 · 開放 ${result.compositeOpenCount} 中心
+
+
+
戀愛關係

${theme.love}

+
工作夥伴

${theme.work}

+
`) + + const connSection = section('四種核心連結動力', + (['electromagnetic', 'companionship', 'compromise', 'dominance'] as const).map(type => { + const cfg = CONN_META[type] + const items = result[type] + const rows = items.length === 0 + ? `
無相關通道
` + : items.map((conn, i) => + `
+
+ ${conn.channelId} + ${CENTER_NAME[conn.centerA] ?? conn.centerA}—${CENTER_NAME[conn.centerB] ?? conn.centerB} +
+ ${nameA}:${conn.aGates.length ? conn.aGates.join(', ') : '—'} / ${nameB}:${conn.bGates.length ? conn.bGates.join(', ') : '—'} +
` + ).join('') + return `
+
+ ${cfg.label}(${items.length}) + ${cfg.desc} +
${rows}
` + }).join('') + ) + + const definedChIds = result.compositeDefinedChannelIds ?? [] + const channelsSection = definedChIds.length > 0 + ? section(`合圖定義通道(${definedChIds.length})`, channelTagsFor(definedChIds)) + : '' + + const resonanceItems = (result.profileResonance ?? []) + .map(line => PROFILE_RESONANCE_DESC[line]) + .filter((info): info is { title: string; desc: string } => !!info) + const resonanceSection = section('人生角色共鳴', + `
+ ${nameA} ${aProfile} + ${nameB} ${bProfile} +
` + (resonanceItems.length === 0 + ? `

兩人人生角色沒有共同爻線,各自的觀點框架較為不同。

` + : resonanceItems.map(info => + `
${info.title}${info.desc}
` + ).join('') + ) + ) + + const authoritySection = section('策略與內在權威', + `
+
+
${nameA} 的權威
+
${aAuth}
+ ${aAuthTip ? `

${aAuthTip}

` : ''} +
+
+
${nameB} 的權威
+
${bAuth}
+ ${bAuthTip ? `

${bAuthTip}

` : ''} +
+
`) + + return ` + + + + + + + +

${nameA} x ${nameB} 合圖

+ +
+
+
${nameA}
+
${aDate} · ${aTime}
+
${aCity}
+
${aType} · ${aProfile}
+
+
+
${nameB}
+
${bDate} · ${bTime}
+
${bCity}
+
${bType} · ${bProfile}
+
+
+ +
+
合圖 Body Graph
+
+ ${nameA}(黑) + ${nameB}(紅) +
+
${svgMarkup}
+
+ + ${planetTable} + ${themeSection} + ${connSection} + ${channelsSection} + ${resonanceSection} + ${authoritySection} + + + +` +} diff --git a/mobile/lib/pdf/htmlHelpers.ts b/mobile/lib/pdf/htmlHelpers.ts new file mode 100644 index 0000000..1058c4e --- /dev/null +++ b/mobile/lib/pdf/htmlHelpers.ts @@ -0,0 +1,67 @@ +import { CENTER_ORDER, HD_CENTERS_INFO } from '@shared/humanDesign/hd-chart-data' +import { findChannelById, normalizeCenterId } from '@/lib/hd-normalizers' + +export function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} + +// ─── PDF 檔名 ────────────────────────────────────────────────────────────────── + +/** 移除檔名不能用的字元(路徑分隔符、萬用字元等),避免使用者自訂名稱裡的符號弄壞檔名 */ +function sanitizeFilenamePart(s: string): string { + const cleaned = s.trim().replace(/[\\/:*?"<>|]+/g, '_') + return cleaned || '未命名' +} + +function timestampForFilename(): string { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` +} + +/** 檔名格式:圖表種類-圖表名稱-下載時間 */ +export function buildPdfFilename(kind: string, name: string | null | undefined): string { + return `${kind}-${sanitizeFilenamePart(name || '未命名')}-${timestampForFilename()}.pdf` +} + +// 以下版面小工具跨三種報告(個人/合圖/流日)共用同一份實作,避免各自維護造成內容/樣式drift +// (這正是這幾輪修正一直在處理的問題——只改一處,三種報告都會跟著對齊) + +export function row(label: string, value: string, accent = false, dim = false): string { + return `
${label}${value}
` +} + +export function tags(items: string[]): string { + return `
${items.map(i => `${i}`).join('')}
` +} + +// 中心/通道都需要區分「已定義(active)」與「未定義」樣式,跟畫面上 Tag 元件的 active 狀態對齊 +function stateTags(items: { label: string; active: boolean }[]): string { + return `
${items.map(i => `${i.label}`).join('')}
` +} + +export function gateTags(gates: number[]): string { + return `
${[...gates].sort((a, b) => a - b).map(g => `${g}`).join('')}
` +} + +export function section(title: string, body: string): string { + return `
${title}
${body}
` +} + +/** 九大中心:跟畫面(preview.tsx / [id].tsx / CompositeInfo.tsx / TransitAnalysis.tsx)算法一致, + * 全部 9 個中心都列出、用中文名稱,並標示是否已定義 */ +export function centerTagsFor(rawCenterIds: string[]): string { + const defined = new Set(rawCenterIds.map(normalizeCenterId)) + return stateTags(CENTER_ORDER.map(k => ({ + label: HD_CENTERS_INFO[k]?.name.zh ?? k, + active: defined.has(k), + }))) +} + +/** 定義通道:畫面上一律以 active(強調色)樣式呈現,並轉換成 34–20 這種可讀格式 */ +export function channelTagsFor(rawChannelIds: string[]): string { + return stateTags(rawChannelIds.map(rawCh => { + const ch = findChannelById(rawCh) + return { label: ch ? `${ch.from}–${ch.to}` : rawCh, active: true } + })) +} diff --git a/mobile/lib/pdf/palette.ts b/mobile/lib/pdf/palette.ts new file mode 100644 index 0000000..2c4f6b4 --- /dev/null +++ b/mobile/lib/pdf/palette.ts @@ -0,0 +1,20 @@ +import { lightColors, darkColors } from '@/constants/tokens' +import type { PdfThemeMode } from './types' + +/** PDF 文件外觀(背景/文字/邊框等版面色)跟著 app 目前的明暗主題走,圖表本身的閘門/中心配色不受影響 */ +export function pdfPalette(mode: PdfThemeMode) { + const c = mode === 'dark' ? darkColors : lightColors + return { + bg: c.bg, + ink: c.text, + crimson: c.accent, + sub: c.sub, + cardBg: c.surface, + border: c.border, + dimBg: c.gateBg, + altRow: c.altRowBg, + paperDeep: c.gateBg, // 對應網頁版 --paper-deep,用於頁尾商標色帶 + accentBg: c.accentD, // 對應畫面 Tag active 的底色 + dimText: c.planetRedText, // 對應畫面 Row dim 的文字色 + } +} diff --git a/mobile/lib/pdf/personalChartHtml.ts b/mobile/lib/pdf/personalChartHtml.ts new file mode 100644 index 0000000..ffb2901 --- /dev/null +++ b/mobile/lib/pdf/personalChartHtml.ts @@ -0,0 +1,180 @@ +import { ACT_UNCONSCIOUS } from '@shared/humanDesign/hd-chart-data' +import type { PendingChart } from '@/lib/pendingChart' +import { getTypeLabel, getTypeMeta } from '@/lib/hd-type-meta' +import { pdfPalette } from './palette' +import { buildBodyGraphSvg } from './bodyGraphSvg' +import { row, section, centerTagsFor, channelTagsFor, gateTags } from './htmlHelpers' +import type { PdfThemeMode } from './types' + +export function buildPersonalChartHtml(chart: PendingChart, mode: PdfThemeMode): string { + const { bg, ink, crimson, sub, cardBg, border, dimBg, altRow, paperDeep, accentBg, dimText } = pdfPalette(mode) + + const svgMarkup = buildBodyGraphSvg(chart) + + const typeMeta = getTypeMeta(chart.type) + const centerTags = centerTagsFor(chart.centers) + const channelTags = channelTagsFor(chart.channels) + + const crossSection = chart.incarnationCross ? section('輪迴交叉', + row('交叉類型', chart.incarnationCross.crossTypeLabel, true) + + row('交叉名稱', `${chart.incarnationCross.crossBaseName}${chart.incarnationCross.variant}`) + + row('完整名稱', `${chart.incarnationCross.crossTypeLabel}之${chart.incarnationCross.crossBaseName}${chart.incarnationCross.variant}`) + + row('閘門組合', chart.incarnationCross.gatesLabel) + ) : '' + + const arrowsSection = (chart.variables && chart.arrows) ? section('四箭頭(Variables)', + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
方向類別項目說明
${chart.arrows.topLeft ? '←' : '→'}飲食
Digestion
Design 太陽
${chart.variables.digestion.label}${chart.variables.digestion.description}
${chart.arrows.bottomLeft ? '←' : '→'}環境
Environment
Design 北交點
${chart.variables.environment.label}${chart.variables.environment.description}
${chart.arrows.topRight ? '←' : '→'}動機
Motivation
Pers. 太陽
${chart.variables.motivation.label}${chart.variables.motivation.description}
${chart.arrows.bottomRight ? '←' : '→'}觀點
Perspective
Pers. 北交點
${chart.variables.perspective.label}${chart.variables.perspective.description}
` + ) : '' + + const planetsSection = chart.planets && chart.planets.length > 0 ? section('行星閘門對照', + ` + + + + + + + ${chart.planets.map((p, i) => + ` + + + + ` + ).join('')} + +
行星● 意識(黑)● 潛意識(紅)
${p.name}${p.blackGate}.${p.blackLine}${p.redGate}.${p.redLine}
` + ) : '' + + return ` + + + + + + + +

${chart.name || '人類圖本命盤'}

+

${chart.birthDate} ${chart.birthTime} ${chart.birthCity}${chart.timezone ? ` ${chart.timezone}` : ''}

+ + +
+
Body Graph
+
+ 意識(黑) + 潛意識(紅) +
+
${svgMarkup}
+
+ + + ${section('類型', + row('能量類型', getTypeLabel(chart.type), true) + + row('策略', typeMeta.strategy) + + row('簽名(成功徵兆)', typeMeta.signature, true) + + row('非自我主題', typeMeta.notSelf, false, true) + )} + + + ${section('設計', + row('內在權威', chart.authority, true) + + row('人生角色(Profile)', chart.profile) + + row('定義', chart.definition) + )} + + + ${section('九大中心', centerTags)} + + + ${chart.channels.length > 0 ? section(`定義通道(${chart.channels.length})`, channelTags) : ''} + + + ${planetsSection} + + + ${crossSection} + + + ${arrowsSection} + + + ${section(`激活閘門(${chart.gates.length})`, gateTags(chart.gates))} + + + +` +} diff --git a/mobile/lib/pdf/transitHtml.ts b/mobile/lib/pdf/transitHtml.ts new file mode 100644 index 0000000..0f753de --- /dev/null +++ b/mobile/lib/pdf/transitHtml.ts @@ -0,0 +1,175 @@ +import { ACT_UNCONSCIOUS } from '@shared/humanDesign/hd-chart-data' +import { lightColors, darkColors } from '@/constants/tokens' +import type { CreateTransitResult } from '@/lib/api' +import type { PendingChart } from '@/lib/pendingChart' +import { IMPACT_LABEL, IMPACT_DESC, type ImpactKind } from '@/components/transit/ImpactCard' +import { pdfPalette } from './palette' +import { buildBodyGraphSvg } from './bodyGraphSvg' +import { section, centerTagsFor, channelTagsFor, gateTags } from './htmlHelpers' +import type { PdfThemeMode, GateActivation } from './types' + +export function buildTransitChartHtml(result: CreateTransitResult, mode: PdfThemeMode): string { + const { bg, ink, crimson, sub, cardBg, border, dimBg, altRow, paperDeep, accentBg } = pdfPalette(mode) + const tc = mode === 'dark' ? darkColors : lightColors + + // 流日圖的黑/紅語意跟個人圖不同:黑=個人(personality+design 都算),紅=今日流日, + // 兩者都激活時疊成條紋——這裡要跟畫面共用的 buildTransitBodyGraphProps() 算法完全一致, + // 不能沿用 buildActivations() 預設的 personality/design 判斷法(那是給個人圖用的) + const activations: Record = {} + for (const g of result.personalityGates) activations[g] = { ...activations[g], c: true } + for (const g of result.designGates) activations[g] = { ...activations[g], c: true } + for (const g of result.transit.allGates) activations[g] = { ...activations[g], u: true } + + const svgChart: PendingChart = { + name: '流日', + birthDate: '', birthTime: '', birthCity: '', timezone: '', + type: '', authority: '', profile: '', definition: '', + centers: result.combined.definedCenterIds, + channels: result.combined.definedChannelIds, + gates: [], + } + const svgMarkup = buildBodyGraphSvg(svgChart, activations) + + const now = new Date() + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}` + + // Format transit date + const transitDate = result.transit.computedAt + ? new Date(result.transit.computedAt).toLocaleString('zh-TW', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : ts + + // 今日行星閘門:跟畫面(TransitView.tsx)一致,個人的潛意識/意識 + 今日流日三欄並排比較 + const transitPlanetsTable = result.transit.planets.length > 0 + ? section('今日行星閘門', + ` + + + + + + + + ${result.transit.planets.map((tp, i) => { + const pp = result.personalPlanets?.find(p => p.planetName === tp.planetName) + return ` + + + + + ` + }).join('')} + +
行星潛意識意識流日
${tp.planetName}${pp ? `${pp.design.gate}.${pp.design.line}` : '—'}${pp ? `${pp.personality.gate}.${pp.personality.line}` : '—'}${tp.gate}.${tp.line}
`) + : '' + + // 流日影響:跟畫面(TransitView.tsx / TransitAnalysis.tsx 共用的 ImpactCard)一致, + // 依 kind 分組成卡片(標籤 chips + 固定說明文案),不是每個 layer 各自一行 label/detail + const impactKinds = ['center-activated', 'new-channel', 'completing-channel'] as const + const impactColor: Record = { + 'center-activated': tc.em, + 'new-channel': tc.transit, + 'completing-channel': tc.compro, + } + const impactCards = impactKinds.map(kind => { + const items = result.impact.layers.filter(l => l.kind === kind) + if (items.length === 0) return '' + const color = impactColor[kind] + return `
+
${IMPACT_LABEL[kind]}
+
${items.map(l => `${l.label}`).join('')}
+

${IMPACT_DESC[kind]}

+
` + }).join('') + + const impactSection = result.impact.layers.length > 0 + ? section('流日影響分析', impactCards) + : section('流日影響分析', `
今日流日對此圖表影響不顯著
`) + + const personalGateSet = new Set(result.personalGates) + const transitOnlyGates = result.transit.allGates.filter(g => !personalGateSet.has(g)) + + return ` + + + + + + + +

個人圖 × 今日流日分析

+

流日時間:${transitDate}

+ + +
+
個人 + 流日 Body Graph
+
+ 個人 + 流日 + 共有 +
+
${svgMarkup}
+
+ + + ${transitPlanetsTable} + + + ${impactSection} + + + ${section(`合成已定義中心(${result.combined.definedCenterIds.length})`, centerTagsFor(result.combined.definedCenterIds))} + + + ${result.combined.definedChannelIds.length > 0 ? section(`合成定義通道(${result.combined.definedChannelIds.length})`, channelTagsFor(result.combined.definedChannelIds)) : ''} + + + ${result.personalGates.length > 0 ? section(`個人激活閘門(${result.personalGates.length})`, gateTags(result.personalGates)) : ''} + + + ${transitOnlyGates.length > 0 ? section(`今日流日新增閘門(${transitOnlyGates.length})`, gateTags(transitOnlyGates)) : ''} + + + +` +} diff --git a/mobile/lib/pdf/types.ts b/mobile/lib/pdf/types.ts new file mode 100644 index 0000000..f114cac --- /dev/null +++ b/mobile/lib/pdf/types.ts @@ -0,0 +1,4 @@ +export type PdfThemeMode = 'light' | 'dark' + +/** 閘門的意識(c=personality/黑)/潛意識(u=design/紅)啟動狀態 */ +export type GateActivation = { c?: boolean; u?: boolean } From 0661edbec5f9481b8e0d31beb946b9e75fa5a139 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Thu, 6 Aug 2026 16:54:15 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor(web):=20=E4=BE=9D=20FP=20=E7=9A=84?= =?UTF-8?q?=20Actions/Calculations/Data=20=E6=8B=86=E5=88=86=20saveChart.t?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveChart/saveTransitChart/saveCompositeChart 原本各自把「組 API payload」跟「fetch 存檔」揉在同一函式裡。拆出 lib/chartPayloads.ts 放三個純 payload 組裝函式,saveChart.ts 只留三個薄 Action,共用同一個 postChartPayload() 處理 fetch + 錯誤訊息,呼叫端介面不變。 Co-Authored-By: Claude Sonnet 5 --- lib/chartPayloads.ts | 196 +++++++++++++++++++++++++++++++++++++++ lib/saveChart.ts | 214 ++++--------------------------------------- 2 files changed, 216 insertions(+), 194 deletions(-) create mode 100644 lib/chartPayloads.ts diff --git a/lib/chartPayloads.ts b/lib/chartPayloads.ts new file mode 100644 index 0000000..3ade736 --- /dev/null +++ b/lib/chartPayloads.ts @@ -0,0 +1,196 @@ +import type { HdResult } from '@/lib/buildAiPrompt' +import type { CenterName, ChannelDef } from '@/lib/humanDesign/types' +import type { TransitPlanetRow } from '@/lib/computeTransit' +import { CROSS_TYPE_LABELS } from '@/lib/humanDesign/constants' + +// ─── 個人圖 ──────────────────────────────────────────────────────────────────── + +export interface ChartPayloadParams { + result: HdResult + date: string + time: string + locationLabel: string + timezone: string +} + +export function buildChartPayload({ result, date, time, locationLabel, timezone }: ChartPayloadParams) { + return { + name: `${locationLabel} · ${date}`, + birthDate: date, + birthTime: time, + birthCity: locationLabel, + timezone, + type: result.type, + authority: result.authority.name, + profile: result.profile.profile, + definition: result.definition.label, + centers: [...result.definedCenterIds], + channels: result.definedChannels.map(ch => ch.id), + gates: [...result.allGates], + incarnationCross: { + crossType: result.incarnationCross.crossType, + crossTypeLabel: CROSS_TYPE_LABELS[result.incarnationCross.crossType] ?? result.incarnationCross.crossType, + crossBaseName: result.incarnationCross.crossBaseName, + crossName: result.incarnationCross.crossName, + gatesLabel: result.incarnationCross.gatesLabel, + variant: result.incarnationCross.variant, + sunGate: result.incarnationCross.persSunGate, + }, + variables: { + digestion: result.variables.digestion, + environment: result.variables.environment, + perspective: result.variables.perspective, + motivation: result.variables.motivation, + }, + arrows: { + topLeft: (result.planets[0]?.red.tone ?? 1) <= 3, + bottomLeft: (result.planets[3]?.red.tone ?? 1) <= 3, + topRight: (result.planets[0]?.black.tone ?? 1) <= 3, + bottomRight: (result.planets[3]?.black.tone ?? 1) <= 3, + }, + planets: (result.planets ?? []).map(p => ({ + name: p.planetName, + blackGate: p.black.gate, + blackLine: p.black.line, + redGate: p.red.gate, + redLine: p.red.line, + })), + // black = Personality(意識),red = Design(潛意識) + personalityGates: (result.planets ?? []).map(p => p.black.gate), + designGates: (result.planets ?? []).map(p => p.red.gate), + } +} + +// ─── 流日圖 ──────────────────────────────────────────────────────────────────── + +export interface TransitChartPayloadParams { + personal: HdResult + personalBirthDate: string + personalBirthTime: string + personalBirthCity: string + personalTimezone: string + transitComputedAt: string + transitAllGates: Set + transitDefinedCenterIds: Set + transitDefinedChannels: ChannelDef[] + transitPlanets: TransitPlanetRow[] +} + +/** 將流日圖轉成 /api/charts 的 payload,type 取個人類型,chartKind='transit'。 + * meta.transitMeta 額外保留個人出生資料與流日行星閘門,供之後重新顯示完整流日圖(而非誤讀為個人圖)。 */ +export function buildTransitChartPayload(p: TransitChartPayloadParams) { + // Convert UTC ISO to Taipei time (UTC+8) without relying on toLocaleString + const taipeiDate = (() => { + const d = new Date(new Date(p.transitComputedAt).getTime() + 8 * 60 * 60 * 1000) + return isNaN(d.getTime()) ? new Date(Date.now() + 8 * 60 * 60 * 1000) : d + })() + const transitDate = taipeiDate.toISOString().slice(0, 10) + const transitTime = `${String(taipeiDate.getUTCHours()).padStart(2, '0')}:${String(taipeiDate.getUTCMinutes()).padStart(2, '0')}` + + return { + name: `流日圖 · ${transitDate}`, + birthDate: transitDate, + birthTime: transitTime, + birthCity: '流日', + timezone: 'Asia/Taipei', + type: p.personal.type, + authority: p.personal.authority.name, + profile: p.personal.profile.profile, + definition: p.personal.definition.label, + centers: [...p.transitDefinedCenterIds], + channels: p.transitDefinedChannels.map(ch => ch.id), + gates: [...p.transitAllGates], + chartKind: 'transit', + incarnationCross: { + crossType: p.personal.incarnationCross.crossType, + crossTypeLabel: CROSS_TYPE_LABELS[p.personal.incarnationCross.crossType] ?? p.personal.incarnationCross.crossType, + crossBaseName: p.personal.incarnationCross.crossBaseName, + crossName: p.personal.incarnationCross.crossName, + gatesLabel: p.personal.incarnationCross.gatesLabel, + variant: p.personal.incarnationCross.variant, + sunGate: p.personal.incarnationCross.persSunGate, + }, + variables: { + digestion: p.personal.variables.digestion, + environment: p.personal.variables.environment, + perspective: p.personal.variables.perspective, + motivation: p.personal.variables.motivation, + }, + arrows: { + topLeft: (p.personal.planets[0]?.red.tone ?? 1) <= 3, + bottomLeft: (p.personal.planets[3]?.red.tone ?? 1) <= 3, + topRight: (p.personal.planets[0]?.black.tone ?? 1) <= 3, + bottomRight: (p.personal.planets[3]?.black.tone ?? 1) <= 3, + }, + planets: (p.personal.planets ?? []).map(pl => ({ + name: pl.planetName, + blackGate: pl.black.gate, + blackLine: pl.black.line, + redGate: pl.red.gate, + redLine: pl.red.line, + })), + personalityGates: (p.personal.planets ?? []).map(pl => pl.black.gate), + designGates: (p.personal.planets ?? []).map(pl => pl.red.gate), + transitMeta: { + personalBirthDate: p.personalBirthDate, + personalBirthTime: p.personalBirthTime, + personalBirthCity: p.personalBirthCity, + personalTimezone: p.personalTimezone, + transitComputedAt: p.transitComputedAt, + transitPlanets: p.transitPlanets.map(pl => ({ + planetName: pl.planetName, + gate: pl.gate, + line: pl.line, + full: pl.full, + })), + }, + } +} + +// ─── 合圖 ────────────────────────────────────────────────────────────────────── + +export interface CompositeChartPayloadParams { + resultA: HdResult + resultB: HdResult + dateA: string; timeA: string; locationA: string; timezoneA: string + dateB: string; timeB: string; locationB: string; timezoneB: string + compositeDefinedCenterIds: Set + compositeDefinedChannels: ChannelDef[] + compositeAllGates: Set +} + +/** 將合圖轉成 /api/charts 的 payload,type='composite',兩人資料以 '|' 分隔編碼。 */ +export function buildCompositeChartPayload(p: CompositeChartPayloadParams) { + const requiredFields = [p.dateA, p.dateB, p.timeA, p.timeB, p.locationA, p.locationB, p.timezoneA, p.timezoneB] + if (requiredFields.some(f => !f)) throw new Error('合圖欄位不完整,無法儲存') + + const authorityA = p.resultA.authority?.name + const authorityB = p.resultB.authority?.name + const profileA = p.resultA.profile?.profile + const profileB = p.resultB.profile?.profile + const definitionA = p.resultA.definition?.label + const definitionB = p.resultB.definition?.label + if (!authorityA || !authorityB || !profileA || !profileB || !definitionA || !definitionB) { + throw new Error('合圖計算結果不完整,無法儲存') + } + + const centers = [...p.compositeDefinedCenterIds] + const channels = p.compositeDefinedChannels.map(ch => ch.id).filter(Boolean) + const gates = [...p.compositeAllGates].filter(g => typeof g === 'number') + + return { + name: `${p.locationA} × ${p.locationB}`, + birthDate: `${p.dateA}|${p.dateB}`, + birthTime: `${p.timeA}|${p.timeB}`, + birthCity: `${p.locationA}|${p.locationB}`, + timezone: `${p.timezoneA}|${p.timezoneB}`, + type: 'composite', + authority: `${authorityA} / ${authorityB}`, + profile: `${profileA} / ${profileB}`, + definition: `${definitionA} / ${definitionB}`, + centers, + channels, + gates, + chartKind: 'composite', + } +} diff --git a/lib/saveChart.ts b/lib/saveChart.ts index 78edaa3..f47643b 100644 --- a/lib/saveChart.ts +++ b/lib/saveChart.ts @@ -1,208 +1,34 @@ -import type { HdResult } from '@/lib/buildAiPrompt' -import type { CenterName, ChannelDef } from '@/lib/humanDesign/types' -import type { TransitPlanetRow } from '@/lib/computeTransit' -import { CROSS_TYPE_LABELS } from '@/lib/humanDesign/constants' - -export interface SaveChartParams { - result: HdResult - date: string - time: string - locationLabel: string - timezone: string -} - -export const saveChart = async ({ result, date, time, locationLabel, timezone }: SaveChartParams): Promise => { +import { + buildChartPayload, + buildTransitChartPayload, + buildCompositeChartPayload, + type ChartPayloadParams, + type TransitChartPayloadParams, + type CompositeChartPayloadParams, +} from '@/lib/chartPayloads' + +export type { ChartPayloadParams, TransitChartPayloadParams, CompositeChartPayloadParams } + +async function postChartPayload(payload: unknown): Promise { const res = await fetch('/api/charts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `${locationLabel} · ${date}`, - birthDate: date, - birthTime: time, - birthCity: locationLabel, - timezone, - type: result.type, - authority: result.authority.name, - profile: result.profile.profile, - definition: result.definition.label, - centers: [...result.definedCenterIds], - channels: result.definedChannels.map(ch => ch.id), - gates: [...result.allGates], - incarnationCross: { - crossType: result.incarnationCross.crossType, - crossTypeLabel: CROSS_TYPE_LABELS[result.incarnationCross.crossType] ?? result.incarnationCross.crossType, - crossBaseName: result.incarnationCross.crossBaseName, - crossName: result.incarnationCross.crossName, - gatesLabel: result.incarnationCross.gatesLabel, - variant: result.incarnationCross.variant, - sunGate: result.incarnationCross.persSunGate, - }, - variables: { - digestion: result.variables.digestion, - environment: result.variables.environment, - perspective: result.variables.perspective, - motivation: result.variables.motivation, - }, - arrows: { - topLeft: (result.planets[0]?.red.tone ?? 1) <= 3, - bottomLeft: (result.planets[3]?.red.tone ?? 1) <= 3, - topRight: (result.planets[0]?.black.tone ?? 1) <= 3, - bottomRight: (result.planets[3]?.black.tone ?? 1) <= 3, - }, - planets: (result.planets ?? []).map(p => ({ - name: p.planetName, - blackGate: p.black.gate, - blackLine: p.black.line, - redGate: p.red.gate, - redLine: p.red.line, - })), - // black = Personality(意識),red = Design(潛意識) - personalityGates: (result.planets ?? []).map(p => p.black.gate), - designGates: (result.planets ?? []).map(p => p.red.gate), - }), + body: JSON.stringify(payload), }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? '儲存失敗') } -export interface SaveTransitChartParams { - personal: HdResult - personalBirthDate: string - personalBirthTime: string - personalBirthCity: string - personalTimezone: string - transitComputedAt: string - transitAllGates: Set - transitDefinedCenterIds: Set - transitDefinedChannels: ChannelDef[] - transitPlanets: TransitPlanetRow[] +export const saveChart = async (params: ChartPayloadParams): Promise => { + await postChartPayload(buildChartPayload(params)) } -/** 將流日圖存成 Chart 記錄,type 取個人類型,chartKind='transit'。 - * meta.transitMeta 額外保留個人出生資料與流日行星閘門,供之後重新顯示完整流日圖(而非誤讀為個人圖)。 */ -export const saveTransitChart = async (p: SaveTransitChartParams): Promise => { - // Convert UTC ISO to Taipei time (UTC+8) without relying on toLocaleString - const taipeiDate = (() => { - const d = new Date(new Date(p.transitComputedAt).getTime() + 8 * 60 * 60 * 1000) - return isNaN(d.getTime()) ? new Date(Date.now() + 8 * 60 * 60 * 1000) : d - })() - const transitDate = taipeiDate.toISOString().slice(0, 10) - const transitTime = `${String(taipeiDate.getUTCHours()).padStart(2, '0')}:${String(taipeiDate.getUTCMinutes()).padStart(2, '0')}` - - const res = await fetch('/api/charts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `流日圖 · ${transitDate}`, - birthDate: transitDate, - birthTime: transitTime, - birthCity: '流日', - timezone: 'Asia/Taipei', - type: p.personal.type, - authority: p.personal.authority.name, - profile: p.personal.profile.profile, - definition: p.personal.definition.label, - centers: [...p.transitDefinedCenterIds], - channels: p.transitDefinedChannels.map(ch => ch.id), - gates: [...p.transitAllGates], - chartKind: 'transit', - incarnationCross: { - crossType: p.personal.incarnationCross.crossType, - crossTypeLabel: CROSS_TYPE_LABELS[p.personal.incarnationCross.crossType] ?? p.personal.incarnationCross.crossType, - crossBaseName: p.personal.incarnationCross.crossBaseName, - crossName: p.personal.incarnationCross.crossName, - gatesLabel: p.personal.incarnationCross.gatesLabel, - variant: p.personal.incarnationCross.variant, - sunGate: p.personal.incarnationCross.persSunGate, - }, - variables: { - digestion: p.personal.variables.digestion, - environment: p.personal.variables.environment, - perspective: p.personal.variables.perspective, - motivation: p.personal.variables.motivation, - }, - arrows: { - topLeft: (p.personal.planets[0]?.red.tone ?? 1) <= 3, - bottomLeft: (p.personal.planets[3]?.red.tone ?? 1) <= 3, - topRight: (p.personal.planets[0]?.black.tone ?? 1) <= 3, - bottomRight: (p.personal.planets[3]?.black.tone ?? 1) <= 3, - }, - planets: (p.personal.planets ?? []).map(pl => ({ - name: pl.planetName, - blackGate: pl.black.gate, - blackLine: pl.black.line, - redGate: pl.red.gate, - redLine: pl.red.line, - })), - personalityGates: (p.personal.planets ?? []).map(pl => pl.black.gate), - designGates: (p.personal.planets ?? []).map(pl => pl.red.gate), - transitMeta: { - personalBirthDate: p.personalBirthDate, - personalBirthTime: p.personalBirthTime, - personalBirthCity: p.personalBirthCity, - personalTimezone: p.personalTimezone, - transitComputedAt: p.transitComputedAt, - transitPlanets: p.transitPlanets.map(pl => ({ - planetName: pl.planetName, - gate: pl.gate, - line: pl.line, - full: pl.full, - })), - }, - }), - }) - const json = await res.json() - if (!res.ok) throw new Error(json.error ?? '儲存失敗') -} - -export interface SaveCompositeChartParams { - resultA: HdResult - resultB: HdResult - dateA: string; timeA: string; locationA: string; timezoneA: string - dateB: string; timeB: string; locationB: string; timezoneB: string - compositeDefinedCenterIds: Set - compositeDefinedChannels: ChannelDef[] - compositeAllGates: Set +/** 將流日圖存成 Chart 記錄,type 取個人類型,chartKind='transit'。 */ +export const saveTransitChart = async (params: TransitChartPayloadParams): Promise => { + await postChartPayload(buildTransitChartPayload(params)) } /** 將合圖存成單一 Chart 記錄,type='composite',兩人資料以 '|' 分隔編碼。 */ -export const saveCompositeChart = async (p: SaveCompositeChartParams): Promise => { - const requiredFields = [p.dateA, p.dateB, p.timeA, p.timeB, p.locationA, p.locationB, p.timezoneA, p.timezoneB] - if (requiredFields.some(f => !f)) throw new Error('合圖欄位不完整,無法儲存') - - const authorityA = p.resultA.authority?.name - const authorityB = p.resultB.authority?.name - const profileA = p.resultA.profile?.profile - const profileB = p.resultB.profile?.profile - const definitionA = p.resultA.definition?.label - const definitionB = p.resultB.definition?.label - if (!authorityA || !authorityB || !profileA || !profileB || !definitionA || !definitionB) { - throw new Error('合圖計算結果不完整,無法儲存') - } - - const centers = [...p.compositeDefinedCenterIds] - const channels = p.compositeDefinedChannels.map(ch => ch.id).filter(Boolean) - const gates = [...p.compositeAllGates].filter(g => typeof g === 'number') - - const res = await fetch('/api/charts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `${p.locationA} × ${p.locationB}`, - birthDate: `${p.dateA}|${p.dateB}`, - birthTime: `${p.timeA}|${p.timeB}`, - birthCity: `${p.locationA}|${p.locationB}`, - timezone: `${p.timezoneA}|${p.timezoneB}`, - type: 'composite', - authority: `${authorityA} / ${authorityB}`, - profile: `${profileA} / ${profileB}`, - definition: `${definitionA} / ${definitionB}`, - centers, - channels, - gates, - chartKind: 'composite', - }), - }) - const json = await res.json() - if (!res.ok) throw new Error(json.error ?? '儲存失敗') +export const saveCompositeChart = async (params: CompositeChartPayloadParams): Promise => { + await postChartPayload(buildCompositeChartPayload(params)) } From 58b6074e679b3b3115d2451535dc91641231b1dc Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Thu, 6 Aug 2026 18:09:28 +0800 Subject: [PATCH 3/9] =?UTF-8?q?refactor(web):=20=E6=8B=86=E5=88=86=20Accou?= =?UTF-8?q?ntClient.tsx=20=E7=A5=9E=E7=B4=9A=E5=85=83=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E4=B8=A6=E9=A1=AF=E7=A4=BA=E7=9B=AE=E5=89=8D=E5=9C=96=E8=A1=A8?= =?UTF-8?q?=E5=90=8D=E7=A8=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountContent() 原本 894 行混雜三個分頁(個人資料/我的圖表/通知)的 全部 state、effect、渲染。拆成 ProfileSection.tsx/NotificationsSection.tsx 兩個自帶 hook 的獨立元件,圖表重算邏輯拆成 useActiveChartRecompute (Action) + lib/chartRecompute.ts 純函式(Calculation),順便合併原本 重算 effect 與渲染各寫一份的合圖出生資料解析邏輯。AccountClient.tsx 從 953 行降到 440 行左右。 ProfileSection/NotificationsSection 用 isActive prop 控制渲染但元件本身 無條件掛載,保留原本「切分頁不重置編輯草稿」「通知查詢 enabled 開關」 的行為。另外「我的圖表」標題補上目前選取圖表的名稱。 Co-Authored-By: Claude Sonnet 5 --- app/account/AccountClient.tsx | 540 +------------------------ app/account/NotificationsSection.tsx | 171 ++++++++ app/account/ProfileSection.tsx | 216 ++++++++++ app/account/useActiveChartRecompute.ts | 103 +++++ lib/chartRecompute.ts | 99 +++++ 5 files changed, 604 insertions(+), 525 deletions(-) create mode 100644 app/account/NotificationsSection.tsx create mode 100644 app/account/ProfileSection.tsx create mode 100644 app/account/useActiveChartRecompute.ts create mode 100644 lib/chartRecompute.ts diff --git a/app/account/AccountClient.tsx b/app/account/AccountClient.tsx index d28adc5..944dd1b 100644 --- a/app/account/AccountClient.tsx +++ b/app/account/AccountClient.tsx @@ -4,49 +4,26 @@ import { useUser, useClerk } from '@clerk/nextjs' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState, startTransition, useRef, Suspense } from 'react' import dynamic from 'next/dynamic' -import Image from 'next/image' import toast from 'react-hot-toast' import ChartView from '@/components/humanDesign/ChartView' -import BirthProfileManager from '@/components/humanDesign/BirthProfileManager' -import { computeHdResult } from '@/lib/computeHdResult' -import type { HdResult } from '@/lib/buildAiPrompt' -import { computeTransit, type TransitResult } from '@/lib/computeTransit' -import { CHANNEL_DEFS, calculateCentersAndChannels } from '@/lib/humanDesign' -import { toUtcDate, getOffsetFromTimezone } from '@/utils/ephemeris' import { LoadingSpinner } from '@/components/LoadingSpinner' import { ConfirmModal } from '@/components/ConfirmModal' -import { useCharts, type PersonMeta, type ChartMeta, type SavedChart } from '@/lib/useCharts' -import { useNotifications, type NotificationType } from '@/lib/useNotifications' +import { useCharts, type SavedChart } from '@/lib/useCharts' +import { kindOf, parseCompositeBirthInfo, type ChartTab } from '@/lib/chartRecompute' +import { useActiveChartRecompute } from './useActiveChartRecompute' +import ProfileSection from './ProfileSection' +import NotificationsSection from './NotificationsSection' const CompositeView = dynamic(() => import('@/components/humanDesign/CompositeView'), { ssr: false }) const TransitView = dynamic(() => import('@/components/humanDesign/TransitView'), { ssr: false }) type SidebarSection = 'profile' | 'humandesign' | 'notifications' -const NOTIFICATION_TYPE_CFG: Record = { - feature: { label: '新功能', color: 'var(--olive-text)' }, - bugfix: { label: '問題修正', color: 'var(--crimson)' }, - announcement: { label: '公告', color: 'var(--tan-text)' }, -} - -function formatNotificationDate(iso: string) { - const d = new Date(iso) - return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}` -} - const CHART_TABS = [ { id: 'personal', label: '個人' }, { id: 'composite', label: '合圖' }, { id: 'transit', label: '流日' }, ] as const -type ChartTab = typeof CHART_TABS[number]['id'] - -function kindOf(chart: Pick): ChartTab { - if (chart.chartKind === 'composite' || chart.chartKind === 'transit') return chart.chartKind - // 舊格式合圖:網頁端存 type='合圖' 但沒有設 chartKind - if (chart.type === '合圖' || chart.birthDate?.includes('|')) return 'composite' - return 'personal' -} export default function AccountClient() { return ( @@ -57,7 +34,7 @@ export default function AccountClient() { } function AccountContent() { - const { isLoaded, isSignedIn, user } = useUser() + const { isLoaded, isSignedIn } = useUser() const { signOut } = useClerk() const router = useRouter() const searchParams = useSearchParams() @@ -71,11 +48,6 @@ function AccountContent() { return t === 'composite' || t === 'transit' || t === 'personal' ? t : 'personal' }) - const [chartResult, setChartResult] = useState(null) - const [chartComputing, setChartComputing] = useState(false) - const [compositeResults, setCompositeResults] = useState<{ a: HdResult; b: HdResult } | null>(null) - const [transitSnapshot, setTransitSnapshot] = useState(null) - useEffect(() => { if (isLoaded && !isSignedIn) router.replace('/') }, [isLoaded, isSignedIn, router]) @@ -105,97 +77,7 @@ function AccountContent() { }) }, [charts, chartTab]) - const { - notifications, isAdmin: isNotificationsAdmin, loading: notificationsLoading, - createNotification, creating: creatingNotif, - deleteNotification, deletingId: deletingNotifId, - } = useNotifications(activeSection === 'notifications') - - const [newNotifTitle, setNewNotifTitle] = useState('') - const [newNotifBody, setNewNotifBody] = useState('') - const [newNotifType, setNewNotifType] = useState('announcement') - - const handleCreateNotification = async () => { - if (creatingNotif) return - if (!newNotifTitle.trim() || !newNotifBody.trim()) { - toast.error('標題與內容為必填') - return - } - try { - await createNotification({ title: newNotifTitle.trim(), body: newNotifBody.trim(), type: newNotifType }) - setNewNotifTitle('') - setNewNotifBody('') - setNewNotifType('announcement') - toast.success('已新增通知') - } catch (err) { - console.error('[account] handleCreateNotification error:', err) - toast.error(err instanceof Error ? err.message : '新增失敗') - } - } - - const handleDeleteNotification = async (id: string) => { - if (deletingNotifId) return - try { - await deleteNotification(id) - } catch (err) { - console.error('[account] handleDeleteNotification error:', err) - toast.error('刪除失敗') - } - } - - const [editingName, setEditingName] = useState(false) - const [displayName, setDisplayName] = useState('') - const [nameSaving, setNameSaving] = useState(false) - const [avatarUploading, setAvatarUploading] = useState(false) - const avatarInputRef = useRef(null) - - const handleStartEditName = () => { - if (!user) return - setDisplayName(user.fullName?.trim() || user.username || '') - setEditingName(true) - window.umami?.track('account-edit-name') - } - - const handleSaveName = async () => { - if (nameSaving || !user) return - setNameSaving(true) - window.umami?.track('account-save-name') - try { - const trimmed = displayName.trim() - const spaceIdx = trimmed.indexOf(' ') - const firstName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx) - const lastName = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1) - await user.update({ firstName, lastName }) - setEditingName(false) - toast.success('名稱已更新') - } catch { - toast.error('儲存失敗') - } finally { - setNameSaving(false) - } - } - - const handleAvatarChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file || !user) return - if (file.size > 10 * 1024 * 1024) { - toast.error('圖片大小不能超過 10MB') - if (avatarInputRef.current) avatarInputRef.current.value = '' - return - } - setAvatarUploading(true) - window.umami?.track('account-avatar-upload') - try { - await user.setProfileImage({ file }) - toast.success('頭像已更新') - } catch (err) { - const msg = err instanceof Error ? err.message : '' - toast.error(msg || '頭像上傳失敗') - } finally { - setAvatarUploading(false) - if (avatarInputRef.current) avatarInputRef.current.value = '' - } - } + const { chartResult, chartComputing, compositeResults, transitSnapshot } = useActiveChartRecompute(charts, activeChartId) const [editingChartId, setEditingChartId] = useState(null) const [editingChartName, setEditingChartName] = useState('') @@ -222,10 +104,6 @@ function AccountContent() { const [confirmDeleteId, setConfirmDeleteId] = useState(null) const isDeletingRef = useRef(false) - const chartRequestIdRef = useRef(0) - - const [deletingAccount, setDeletingAccount] = useState(false) - const [confirmDeleteAccount, setConfirmDeleteAccount] = useState(false) const handleDeleteChart = async (id: string) => { if (isDeletingRef.current) return @@ -252,137 +130,6 @@ function AccountContent() { setActiveChartId(filtered[0]?.id ?? null) } - useEffect(() => { - if (!activeChartId) return - const chart = charts.find(c => c.id === activeChartId) - if (!chart) return - - startTransition(() => { - setChartResult(null) - setCompositeResults(null) - setTransitSnapshot(null) - setChartComputing(true) - }) - - const requestId = ++chartRequestIdRef.current - - const isComposite = kindOf(chart) === 'composite' - const isTransit = kindOf(chart) === 'transit' - - if (isTransit) { - const transitMeta = chart.meta?.transitMeta - if (transitMeta) { - computeHdResult(transitMeta.personalBirthDate, transitMeta.personalBirthTime, transitMeta.personalTimezone) - .then(personalResult => { - if (chartRequestIdRef.current !== requestId) return - const definedChannels = chart.channels - .map(id => CHANNEL_DEFS.find(ch => ch.id === id)) - .filter((ch): ch is typeof CHANNEL_DEFS[number] => !!ch) - setChartResult(personalResult) - setTransitSnapshot({ - planets: transitMeta.transitPlanets, - allGates: new Set(chart.gates), - definedCenterIds: new Set(chart.centers), - definedChannels, - computedAt: transitMeta.transitComputedAt, - }) - }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } else if (chart.planets && chart.personalityGates && chart.designGates && chart.timezone) { - // 舊格式流日圖:缺少 meta.transitMeta,但本命閘門與流日計算當下的時刻都還在, - // 可用來精準重建(閘門/通道/中心/流日行星皆可重算,僅箭頭方向與變數顏色因 tone 遺失而無法還原)。 - const legacyPersonalAllGates = new Set([...chart.personalityGates, ...chart.designGates]) - const { definedCenterIds, definedChannels } = calculateCentersAndChannels(legacyPersonalAllGates) - const dummyGate = { gate: 0, line: 0, color: 1, tone: 1, base: 1, full: '' } - const legacyPersonal: HdResult = { - jd: 0, - designJd: 0, - utcTime: '', - designUtcTime: '', - planets: chart.planets.map(p => ({ - planetName: p.name, - black: { gate: p.blackGate, line: p.blackLine, color: 1, tone: 1, base: 1, full: `${p.blackGate}.${p.blackLine}` }, - red: { gate: p.redGate, line: p.redLine, color: 1, tone: 1, base: 1, full: `${p.redGate}.${p.redLine}` }, - display: '', - persLon: 0, - desLon: 0, - })), - profile: { - profile: chart.profile, - personalitySunLine: 0, - designSunLine: 0, - personalitySun: dummyGate, - designSun: dummyGate, - }, - type: chart.type as HdResult['type'], - authority: { name: chart.authority, tip: '' }, - definedCenterIds, - definedChannels, - allGates: legacyPersonalAllGates, - incarnationCross: { - crossType: 'RAC', crossBaseName: '', crossName: '', variant: 1, - conscious: '', unconscious: '', gatesLabel: '', persSunGate: 0, persSunLine: 0, - }, - variables: { - digestion: { label: '', description: '' }, - environment: { label: '', description: '' }, - perspective: { label: '', description: '' }, - motivation: { label: '', description: '' }, - }, - definition: { raw: chart.definition, label: chart.definition }, - } - - const legacyMoment = toUtcDate( - chart.birthDate, - chart.birthTime, - getOffsetFromTimezone(chart.timezone, new Date(`${chart.birthDate}T${chart.birthTime}:00`)), - ) - - computeTransit(legacyMoment) - .then(transit => { - if (chartRequestIdRef.current !== requestId) return - setChartResult(legacyPersonal) - setTransitSnapshot(transit) - }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } else { - startTransition(() => setChartComputing(false)) - toast.error('這份流日圖是舊格式儲存,缺少完整資料,無法重新顯示') - } - } else if (isComposite) { - let dateA: string, timeA: string, tzA: string - let dateB: string, timeB: string, tzB: string - - if (chart.meta?.personA && chart.meta?.personB) { - // New format: individual fields in meta - const { personA, personB } = chart.meta as Required - dateA = personA.birthDate; timeA = personA.birthTime; tzA = personA.timezone - dateB = personB.birthDate; timeB = personB.birthTime; tzB = personB.timezone - } else { - // Old format: pipe-separated fields - ;[dateA, dateB] = chart.birthDate.split('|') - ;[timeA, timeB] = chart.birthTime.split('|') - ;[tzA, tzB] = (chart.timezone ?? 'UTC|UTC').split('|') - } - - Promise.all([ - computeHdResult(dateA, timeA, tzA), - computeHdResult(dateB, timeB, tzB), - ]) - .then(([a, b]) => { if (chartRequestIdRef.current === requestId) setCompositeResults({ a, b }) }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } else { - const tz = chart.timezone ?? 'UTC' - computeHdResult(chart.birthDate, chart.birthTime, tz) - .then(r => { if (chartRequestIdRef.current === requestId) setChartResult(r) }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } - }, [activeChartId, charts]) - if (!isLoaded || !isSignedIn) { return ( <> @@ -405,26 +152,6 @@ function AccountContent() { } } - const handleDeleteAccount = async () => { - if (deletingAccount) return - setDeletingAccount(true) - window.umami?.track('account-delete-account') - try { - const res = await fetch('/api/account/delete', { method: 'DELETE' }) - if (!res.ok) { - toast.error('刪除失敗,請稍後再試') - return - } - await signOut() - router.push('/') - } catch { - toast.error('刪除失敗,請稍後再試') - } finally { - setDeletingAccount(false) - setConfirmDeleteAccount(false) - } - } - const handleSectionClick = (section: SidebarSection) => { router.replace(`/account?section=${section}`, { scroll: false }) window.umami?.track('account-section-click', { section }) @@ -564,113 +291,7 @@ function AccountContent() { {/* ── Main content ── */}
- {/* Profile */} - {activeSection === 'profile' && ( -
-
-

- 個人資料 -

-
- -
- {/* Avatar */} -
- - -
- - {/* Name & email */} -
- {!editingName ? ( -
- - {user.fullName?.trim() || user.username || '—'} - - -
- ) : ( -
-
- setDisplayName(e.target.value)} - className="font-mono text-base tracking-[0.04em] border border-(--ink) bg-(--paper) text-(--ink) px-3 py-1.5 w-52 outline-none placeholder:text-(--ink-soft)" - /> -
-
- - -
-
- )} - - - {user.primaryEmailAddress?.emailAddress ?? '—'} - -
-
- - - -
- -
-
- )} + {/* Human Design */} {activeSection === 'humandesign' && ( @@ -679,6 +300,11 @@ function AccountContent() {

我的圖表

+ {activeChart && ( +

+ {activeChart.name ?? `${activeChart.birthCity} · ${activeChart.birthDate}`} +

+ )} {chartsLoading && ( @@ -759,25 +385,7 @@ function AccountContent() { )} {kindOf(activeChart) === 'composite' ? ( compositeResults && (() => { - let cityA: string, cityB: string - let dateA: string, dateB: string - let timeA: string, timeB: string - let tzA: string, tzB: string - - if (activeChart.meta?.personA && activeChart.meta?.personB) { - const pA = activeChart.meta.personA as PersonMeta - const pB = activeChart.meta.personB as PersonMeta - cityA = pA.birthCity; cityB = pB.birthCity - dateA = pA.birthDate; dateB = pB.birthDate - timeA = pA.birthTime; timeB = pB.birthTime - tzA = pA.timezone; tzB = pB.timezone - } else { - ;[cityA, cityB] = activeChart.birthCity.split('|') - ;[dateA, dateB] = activeChart.birthDate.split('|') - ;[timeA, timeB] = activeChart.birthTime.split('|') - ;[tzA, tzB] = (activeChart.timezone ?? 'UTC|UTC').split('|') - } - + const { dateA, timeA, cityA, tzA, dateB, timeB, cityB, tzB } = parseCompositeBirthInfo(activeChart) return ( )} - {/* Notifications */} - {activeSection === 'notifications' && ( -
-
-

- 通知 -

-
- - {isNotificationsAdmin && ( -
-
- 新增通知 -
- setNewNotifTitle(e.target.value)} - disabled={creatingNotif} - className="font-mono text-base tracking-[0.04em] border border-(--ink) bg-(--paper) text-(--ink) px-3 py-1.5 outline-none placeholder:text-(--ink-soft) disabled:opacity-50" - /> -