Ver código fonte

feat: 命盘页优化+后端计算服务增强

chart: 命盘AI解读页布局和交互优化。CalculatorService: 计算引擎增强。新增 EightStarService: 八星数字能量映射服务。dify-signin: Dify登录凭据记录。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 meses atrás
pai
commit
fc3e2eca2e

+ 63 - 55
client/pages/chart/index.vue

@@ -3,8 +3,8 @@
     <!-- Top operation bar -->
     <view class="top-bar">
       <view class="spacer"></view>
-      <view v-if="userStore.isVip" class="export-btn" @click="exportPdf">
-        导出PDF
+      <view class="collapse-btn" @click="chartCollapsed = !chartCollapsed">
+        {{ chartCollapsed ? '▾ 展开命盘图' : '▴ 折叠命盘图' }}
       </view>
     </view>
 
@@ -51,6 +51,7 @@
           <!-- System messages: centered italic text, no bubble -->
           <view v-if="msg.senderType === 'system'" class="msg-system" @longpress.stop="onLongPressMsg(msg)">
             <text>{{ msg.content }}</text>
+            <text class="msg-time msg-time--system">{{ formatTime(msg.createdAt) }}</text>
           </view>
           <!-- Practitioner messages: green bubble with label -->
           <view v-else-if="msg.senderType === 'practitioner'" class="msg-row practitioner" @longpress.stop="onLongPressMsg(msg)">
@@ -58,6 +59,7 @@
             <view class="msg-bubble msg-practitioner-bubble">
               <text>{{ msg.content }}</text>
             </view>
+            <text class="msg-time">{{ formatTime(msg.createdAt) }}</text>
           </view>
           <!-- Voice message — playable audio bubble -->
           <view v-else-if="msg.audioUrl" class="msg-row" :class="msg.role">
@@ -68,12 +70,14 @@
               </view>
               <text class="msg-audio__label">语音</text>
             </view>
+            <text class="msg-time">{{ formatTime(msg.createdAt) }}</text>
           </view>
           <!-- Default (AI/User): text bubble -->
           <view v-else class="msg-row" :class="msg.role">
             <view class="msg-bubble">
               <text>{{ msg.content }}</text>
             </view>
+            <text class="msg-time">{{ formatTime(msg.createdAt) }}</text>
           </view>
         </view>
       </scroll-view>
@@ -244,7 +248,7 @@ import { ref, computed, onMounted } from 'vue'
 import { useChartStore } from '@/stores/chart'
 import { useUserStore } from '@/stores/user'
 import TriangleChart from '@/components/TriangleChart.vue'
-import { chatApi, consultationApi, profileApi, annotationApi, exportApi, interventionApi, planApi, proposalApi, deliveryApi, chartApi } from '@/utils/api'
+import { chatApi, consultationApi, profileApi, annotationApi, exportApi, interventionApi, planApi, proposalApi, deliveryApi } from '@/utils/api'
 
 const store = useChartStore()
 const userStore = useUserStore()
@@ -342,7 +346,8 @@ onMounted(async () => {
         role: m.role === 'ai' ? 'ai' : 'user',
         content: m.content,
         senderType: m.senderType || null,
-        interventionId: m.interventionId || null
+        interventionId: m.interventionId || null,
+        createdAt: m.createdAt
       }))
     } else if (store.currentRecordId) {
       try {
@@ -351,7 +356,8 @@ onMounted(async () => {
           role: m.role === 'ai' ? 'ai' : 'user',
           content: m.content,
           senderType: m.senderType || null,
-          interventionId: m.interventionId || null
+          interventionId: m.interventionId || null,
+          createdAt: m.createdAt
         }))
         const userMsgCount = history.filter(m => m.role === 'user').length
         usedChats.value = Math.max(usedChats.value, userMsgCount)
@@ -367,13 +373,15 @@ onMounted(async () => {
     if (messages.value.length === 0) {
       messages.value = [{
         role: 'ai',
-        content: `已为您生成命盘。主性格数字为${store.currentChart.O}。请点击下方问题或输入您想了解的内容。`
+        content: `已为您生成命盘。主性格数字为${store.currentChart.O}。请点击下方问题或输入您想了解的内容。`,
+        createdAt: new Date().toISOString()
       }]
     }
   } else {
     messages.value = [{
       role: 'ai',
-      content: '暂无命盘数据,请先返回首页输入出生信息。'
+      content: '暂无命盘数据,请先返回首页输入出生信息。',
+      createdAt: new Date().toISOString()
     }]
   }
 })
@@ -515,7 +523,8 @@ async function uploadVoice(filePath) {
 
 async function sendVoiceMessage(audioUrl) {
   // Add voice message locally
-  messages.value.push({ role: 'user', content: '[语音消息]', audioUrl })
+  const now = new Date().toISOString()
+  messages.value.push({ role: 'user', content: '[语音消息]', audioUrl, createdAt: now })
   scrollTop.value = 99999
   loading.value = true
 
@@ -526,7 +535,7 @@ async function sendVoiceMessage(audioUrl) {
         query: '[语音消息]',
         audioUrl
       })
-      messages.value.push({ role: 'ai', content: res.reply })
+      messages.value.push({ role: 'ai', content: res.reply, createdAt: new Date().toISOString() })
       usedChats.value++
     }
   } catch (e) {
@@ -534,12 +543,14 @@ async function sendVoiceMessage(audioUrl) {
       usedChats.value = maxChats.value
       messages.value.push({
         role: 'ai',
-        content: '今日解读次数已用完,开通会员可享无限次解读。'
+        content: '今日解读次数已用完,开通会员可享无限次解读。',
+        createdAt: new Date().toISOString()
       })
     } else {
       messages.value.push({
         role: 'ai',
-        content: e.message || '解读服务暂时不可用,请稍后再试。'
+        content: e.message || '解读服务暂时不可用,请稍后再试。',
+        createdAt: new Date().toISOString()
       })
     }
   }
@@ -607,14 +618,15 @@ async function sendQuestion(q) {
     return
   }
 
-  messages.value.push({ role: 'user', content: q })
+  const now = new Date().toISOString()
+  messages.value.push({ role: 'user', content: q, createdAt: now })
   chatInput.value = ''
   loading.value = true
 
   try {
     if (store.currentRecordId) {
       const res = await chatApi.send({ chartRecordId: store.currentRecordId, query: q })
-      messages.value.push({ role: 'ai', content: res.reply })
+      messages.value.push({ role: 'ai', content: res.reply, createdAt: new Date().toISOString() })
       usedChats.value++
     } else {
       messages.value.push({
@@ -627,12 +639,14 @@ async function sendQuestion(q) {
       usedChats.value = maxChats.value
       messages.value.push({
         role: 'ai',
-        content: '今日解读次数已用完,开通会员可享无限次解读。'
+        content: '今日解读次数已用完,开通会员可享无限次解读。',
+        createdAt: new Date().toISOString()
       })
     } else {
       messages.value.push({
         role: 'ai',
-        content: e.message || '解读服务暂时不可用,请稍后再试。'
+        content: e.message || '解读服务暂时不可用,请稍后再试。',
+        createdAt: new Date().toISOString()
       })
     }
   }
@@ -641,13 +655,22 @@ async function sendQuestion(q) {
   scrollTop.value = 99999
 }
 
+function formatTime(isoStr) {
+  if (!isoStr) return ''
+  const d = new Date(isoStr)
+  const now = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
+  // Same day → show time only; older → show date + time
+  if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
+    return hhmm
+  }
+  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`
+}
+
 // US-3.3: Regenerate interpretation via action sheet menu
 function showMenu() {
   const items = ['🔄 重新开始']
-  // US-9.1: Academic orientation AI (paid users only)
-  if (userStore.isVipActive) {
-    items.push('🎓 学业方向AI')
-  }
   // US-9.2: Apply for manual plan (only when no active plan request)
   if (!planRequest.value) {
     items.push('📝 申请人工方案')
@@ -659,14 +682,11 @@ function showMenu() {
   if (!userStore.isVipActive && shareRemaining.value > 0) {
     items.push('📤 分享给好友')
   }
-  items.push('❌ 取消')
   uni.showActionSheet({
     itemList: items,
     success: async (res) => {
       if (res.tapIndex === 0) {
         await confirmRegenerate()
-      } else if (items[res.tapIndex] === '🎓 学业方向AI') {
-        await handleAcademicOrientation()
       } else if (items[res.tapIndex] === '📝 申请人工方案') {
         await handlePlanApply()
       } else if (items[res.tapIndex] === '📄 导出 PDF') {
@@ -708,38 +728,6 @@ async function confirmRegenerate() {
   })
 }
 
-// US-9.1: Academic orientation AI interpretation
-async function handleAcademicOrientation() {
-  if (!store.currentRecordId) {
-    uni.showToast({ title: '暂无命盘数据', icon: 'none' })
-    return
-  }
-  try {
-    uni.showLoading({ title: 'AI 分析中...' })
-    const result = await chartApi.academicOrientation(store.currentRecordId)
-    uni.hideLoading()
-    if (result && result.content) {
-      // Append academic orientation as a system message in the chat
-      messages.value.push({
-        senderType: 'system',
-        content: '🎓 学业方向AI分析',
-        id: Date.now()
-      })
-      messages.value.push({
-        senderType: 'ai',
-        content: result.content,
-        id: Date.now() + 1
-      })
-      scrollTop.value = 99999
-    } else {
-      uni.showToast({ title: '暂无学业方向分析结果', icon: 'none' })
-    }
-  } catch (e) {
-    uni.hideLoading()
-    uni.showToast({ title: e.message || '学业方向分析失败', icon: 'none' })
-  }
-}
-
 // US-9.2: Navigate to plan application page
 function handlePlanApply() {
   if (!store.currentRecordId) {
@@ -967,7 +955,7 @@ async function submitCounter() {
 .spacer {
   flex: 1;
 }
-.export-btn {
+.collapse-btn {
   font-size: 13px;
   color: #fbbf24;
   padding: 6px 12px;
@@ -1098,6 +1086,7 @@ async function submitCounter() {
 
 .msg-row.user {
   justify-content: flex-end;
+  padding-right: 4px;
 }
 
 .msg-bubble {
@@ -1109,12 +1098,15 @@ async function submitCounter() {
   color: rgba(255, 255, 255, 0.85);
   background: rgba(255, 255, 255, 0.06);
   border: 1px solid rgba(255, 255, 255, 0.06);
+  word-break: break-word;
+  overflow-wrap: break-word;
 }
 
 .msg-row.user .msg-bubble {
   background: rgba(251, 191, 36, 0.12);
   border-color: rgba(251, 191, 36, 0.15);
   color: #fbbf24;
+  margin-left: auto;
 }
 
 /* US-9.3: Practitioner message bubble */
@@ -1136,6 +1128,22 @@ async function submitCounter() {
 }
 
 /* US-9.3: System message — centered italic text */
+.msg-time {
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.25);
+  margin-top: 4px;
+  display: block;
+}
+
+.msg-row.user .msg-time {
+  text-align: right;
+}
+
+.msg-time--system {
+  margin-top: 2px;
+  font-style: normal;
+}
+
 .msg-system {
   text-align: center;
   padding: 8px 0;

+ 31 - 0
dify-signin.txt

@@ -0,0 +1,31 @@
+uid=1_0 RootWebArea "Dify" url="http://dify.bianwoyou.cn/signin"
+  uid=1_1 alert atomic live="assertive" relevant="additions text"
+  uid=1_2 image "Dify logo" url="http://dify.bianwoyou.cn/logo/logo.svg"
+  uid=1_3 button "简体中文" expandable haspopup="menu"
+  uid=1_4 button
+  uid=1_5 heading "登录 Dify" level="2"
+  uid=1_6 StaticText "👋 欢迎!请登录以开始使用。"
+  uid=1_7 StaticText "邮箱"
+  uid=1_8 textbox "邮箱"
+  uid=1_9 StaticText "密码"
+  uid=1_10 link "忘记密码?" url="http://dify.bianwoyou.cn/reset-password?"
+    uid=1_11 StaticText "忘记密码?"
+  uid=1_12 textbox "密码 忘记密码?"
+  uid=1_13 button "😝"
+  uid=1_14 button "登录" disableable disabled
+  uid=1_15 StaticText "使用即代表您同意我们的"
+  uid=1_16 StaticText " "
+  uid=1_17 link "使用协议" url="https://dify.ai/terms"
+    uid=1_18 StaticText "使用协议"
+  uid=1_19 StaticText " & "
+  uid=1_20 link "隐私政策" url="https://dify.ai/privacy"
+    uid=1_21 StaticText "隐私政策"
+  uid=1_22 StaticText "如果您还没有初始化账户,请前往初始化页面"
+  uid=1_23 StaticText " "
+  uid=1_24 link "设置管理员账户" url="http://dify.bianwoyou.cn/install"
+    uid=1_25 StaticText "设置管理员账户"
+  uid=1_26 StaticText "©"
+  uid=1_27 StaticText " "
+  uid=1_28 StaticText "2026"
+  uid=1_29 StaticText " "
+  uid=1_30 StaticText "LangGenius, Inc. All rights reserved."

+ 43 - 0
num-server/src/main/java/com/etotem/num/service/CalculatorService.java

@@ -7,6 +7,12 @@ import java.util.*;
 @Service
 public class CalculatorService {
 
+    private final EightStarService eightStarService;
+
+    public CalculatorService(EightStarService eightStarService) {
+        this.eightStarService = eightStarService;
+    }
+
     /**
      * Reduce a number to a single digit, preserving master numbers 11, 22, 33.
      */
@@ -255,6 +261,43 @@ public class CalculatorService {
         return zone;
     }
 
+    public List<Integer> calculateMissingNumbers(int birthYear, int birthMonth, int birthDay) {
+        String y = String.format("%04d", birthYear);
+        String m = String.format("%02d", birthMonth);
+        String d = String.format("%02d", birthDay);
+        String allDigits = y + m + d;
+        Set<Integer> present = new HashSet<>();
+        for (char c : allDigits.toCharArray()) {
+            int digit = Character.getNumericValue(c);
+            if (digit >= 1 && digit <= 9) { present.add(digit); }
+        }
+        List<Integer> missing = new ArrayList<>();
+        for (int i = 1; i <= 9; i++) { if (!present.contains(i)) missing.add(i); }
+        return missing;
+    }
+
+    public Map<String, Object> calculateTalentNumbers(int birthYear, int birthMonth, int birthDay) {
+        String y = String.format("%04d", birthYear);
+        String m = String.format("%02d", birthMonth);
+        String d = String.format("%02d", birthDay);
+        int yearSum = 0; for (char c : y.toCharArray()) yearSum += Character.getNumericValue(c);
+        int monthSum = 0; for (char c : m.toCharArray()) monthSum += Character.getNumericValue(c);
+        int daySum = 0; for (char c : d.toCharArray()) daySum += Character.getNumericValue(c);
+        int total = yearSum + monthSum + daySum;
+        int reduced = reduceToDigit(total);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("yearSum", yearSum);
+        result.put("yearSumReduced", reduceToDigit(yearSum));
+        result.put("monthSum", monthSum);
+        result.put("monthSumReduced", reduceToDigit(monthSum));
+        result.put("daySum", daySum);
+        result.put("daySumReduced", reduceToDigit(daySum));
+        result.put("totalBeforeReduction", total);
+        result.put("lifePathNumber", reduced);
+        result.put("isMasterNumber", reduced == 11 || reduced == 22 || reduced == 33);
+        return result;
+    }
+
     /**
      * Get the energy meaning for a numerology number.
      */

+ 149 - 0
num-server/src/main/java/com/etotem/num/service/EightStarService.java

@@ -0,0 +1,149 @@
+package com.etotem.num.service;
+
+import org.springframework.stereotype.Service;
+import java.util.*;
+
+@Service
+public class EightStarService {
+
+    public enum StarType {
+        TIAN_YI, SHENG_QI, YAN_NIAN, FU_WEI,
+        JUE_MING, WU_GUI, LIU_SHA, HUO_HAI;
+    }
+
+    public enum EnergyLevel { ONE(1), TWO(2), THREE(3), FOUR(4);
+        public final int level;
+        EnergyLevel(int l) { level = l; }
+    }
+
+    public static class StarPair {
+        public final int pair;
+        public final StarType star;
+        public final EnergyLevel level;
+        public StarPair(int p, StarType s, EnergyLevel l) { pair = p; star = s; level = l; }
+    }
+
+    public static class ChartCombo {
+        public final String pos;
+        public final int a, b, val;
+        public final StarPair info;
+        public final int pri;
+        public ChartCombo(String p, int x, int y, StarPair i, int r) {
+            pos = p; a = x; b = y; val = x * 10 + y; info = i; pri = r;
+        }
+    }
+
+    public static class TriangleAnalysis {
+        public final Map<String,Integer> pos;
+        public final List<ChartCombo> cs;
+        public final int ac, ic;
+        public final String dom;
+        public TriangleAnalysis(Map<String,Integer> p, List<ChartCombo> cc) {
+            pos = p; cs = cc;
+            int a = 0, b = 0;
+            java.util.Map<String,Integer> f = new java.util.HashMap<>();
+            for (ChartCombo c : cc) {
+                if (c.info != null) {
+                    if (c.info.star.ordinal() < 4) a++; else b++;
+                    String n = c.info.star.name();
+                    f.put(n, f.getOrDefault(n, 0) + 1);
+                }
+            }
+            ac = a; ic = b;
+            String d = ""; int m = 0;
+            for (java.util.Map.Entry<String,Integer> e : f.entrySet()) {
+                if (e.getValue() > m) { m = e.getValue(); d = e.getKey(); }
+            }
+            dom = d;
+        }
+    }
+
+    private static final Map<Integer,StarPair> LOOKUP = new HashMap<>();
+    static {
+        // TianYi - Auspicious
+        add(13,StarType.TIAN_YI,EnergyLevel.ONE); add(31,StarType.TIAN_YI,EnergyLevel.ONE);
+        add(68,StarType.TIAN_YI,EnergyLevel.TWO); add(86,StarType.TIAN_YI,EnergyLevel.TWO);
+        add(94,StarType.TIAN_YI,EnergyLevel.THREE); add(49,StarType.TIAN_YI,EnergyLevel.THREE);
+        add(72,StarType.TIAN_YI,EnergyLevel.FOUR); add(27,StarType.TIAN_YI,EnergyLevel.FOUR);
+        // ShengQi - Auspicious
+        add(14,StarType.SHENG_QI,EnergyLevel.ONE); add(41,StarType.SHENG_QI,EnergyLevel.ONE);
+        add(67,StarType.SHENG_QI,EnergyLevel.TWO); add(76,StarType.SHENG_QI,EnergyLevel.TWO);
+        add(93,StarType.SHENG_QI,EnergyLevel.THREE); add(39,StarType.SHENG_QI,EnergyLevel.THREE);
+        add(82,StarType.SHENG_QI,EnergyLevel.FOUR); add(28,StarType.SHENG_QI,EnergyLevel.FOUR);
+        // YanNian - Auspicious
+        add(19,StarType.YAN_NIAN,EnergyLevel.ONE); add(91,StarType.YAN_NIAN,EnergyLevel.ONE);
+        add(87,StarType.YAN_NIAN,EnergyLevel.TWO); add(78,StarType.YAN_NIAN,EnergyLevel.TWO);
+        add(43,StarType.YAN_NIAN,EnergyLevel.THREE); add(34,StarType.YAN_NIAN,EnergyLevel.THREE);
+        add(26,StarType.YAN_NIAN,EnergyLevel.FOUR); add(62,StarType.YAN_NIAN,EnergyLevel.FOUR);
+        // FuWei - Neutral
+        add(11,StarType.FU_WEI,EnergyLevel.ONE); add(22,StarType.FU_WEI,EnergyLevel.ONE);
+        add(99,StarType.FU_WEI,EnergyLevel.TWO); add(88,StarType.FU_WEI,EnergyLevel.TWO);
+        add(77,StarType.FU_WEI,EnergyLevel.THREE); add(66,StarType.FU_WEI,EnergyLevel.THREE);
+        add(44,StarType.FU_WEI,EnergyLevel.FOUR); add(33,StarType.FU_WEI,EnergyLevel.FOUR);
+        // JueMing - Inauspicious
+        add(12,StarType.JUE_MING,EnergyLevel.ONE); add(21,StarType.JUE_MING,EnergyLevel.ONE);
+        add(69,StarType.JUE_MING,EnergyLevel.TWO); add(96,StarType.JUE_MING,EnergyLevel.TWO);
+        add(84,StarType.JUE_MING,EnergyLevel.THREE); add(48,StarType.JUE_MING,EnergyLevel.THREE);
+        add(73,StarType.JUE_MING,EnergyLevel.FOUR); add(37,StarType.JUE_MING,EnergyLevel.FOUR);
+        // WuGui - Inauspicious
+        add(18,StarType.WU_GUI,EnergyLevel.ONE); add(81,StarType.WU_GUI,EnergyLevel.ONE);
+        add(79,StarType.WU_GUI,EnergyLevel.TWO); add(97,StarType.WU_GUI,EnergyLevel.TWO);
+        add(36,StarType.WU_GUI,EnergyLevel.THREE); add(63,StarType.WU_GUI,EnergyLevel.THREE);
+        add(42,StarType.WU_GUI,EnergyLevel.FOUR); add(24,StarType.WU_GUI,EnergyLevel.FOUR);
+        // LiuSha - Inauspicious
+        add(16,StarType.LIU_SHA,EnergyLevel.ONE); add(61,StarType.LIU_SHA,EnergyLevel.ONE);
+        add(47,StarType.LIU_SHA,EnergyLevel.TWO); add(74,StarType.LIU_SHA,EnergyLevel.TWO);
+        add(38,StarType.LIU_SHA,EnergyLevel.THREE); add(83,StarType.LIU_SHA,EnergyLevel.THREE);
+        add(92,StarType.LIU_SHA,EnergyLevel.FOUR); add(29,StarType.LIU_SHA,EnergyLevel.FOUR);
+        // HuoHai - Inauspicious
+        add(17,StarType.HUO_HAI,EnergyLevel.ONE); add(71,StarType.HUO_HAI,EnergyLevel.ONE);
+        add(89,StarType.HUO_HAI,EnergyLevel.TWO); add(98,StarType.HUO_HAI,EnergyLevel.TWO);
+        add(64,StarType.HUO_HAI,EnergyLevel.THREE); add(46,StarType.HUO_HAI,EnergyLevel.THREE);
+        add(32,StarType.HUO_HAI,EnergyLevel.FOUR); add(23,StarType.HUO_HAI,EnergyLevel.FOUR);
+    }
+    private static void add(int p, StarType s, EnergyLevel l) { LOOKUP.put(p, new StarPair(p, s, l)); }
+
+    public StarPair lookup(int p) { return LOOKUP.get(p); }
+    public StarPair lookup(int a, int b) { return lookup(a * 10 + b); }
+
+    public TriangleAnalysis analyze(Map<String,Integer> pos) {
+        List<ChartCombo> cs = new ArrayList<>();
+        // Priority 1: base horizontal
+        addC(cs,pos,"I","J",1); addC(cs,pos,"K","L",1); addC(cs,pos,"M","N",1);
+        // Priority 2: vertical connections
+        addC(cs,pos,"I","M",2); addC(cs,pos,"J","M",2);
+        addC(cs,pos,"K","N",2); addC(cs,pos,"L","N",2);
+        // Priority 3: core personality
+        addC(cs,pos,"M","O",3); addC(cs,pos,"N","O",3);
+        // Priority 4: external
+        addC(cs,pos,"P","Q",4); addC(cs,pos,"S","T",4); addC(cs,pos,"V","W",4);
+        return new TriangleAnalysis(pos, cs);
+    }
+
+    public Map<String,Object> buildJson(TriangleAnalysis ta) {
+        Map<String,Object> r = new LinkedHashMap<>();
+        List<Map<String,Object>> cl = new ArrayList<>();
+        for (ChartCombo c : ta.cs) {
+            Map<String,Object> m = new LinkedHashMap<>();
+            m.put("pair", c.pos); m.put("digits", c.val); m.put("priority", c.pri);
+            if (c.info != null) {
+                Map<String,Object> s = new LinkedHashMap<>();
+                s.put("starType", c.info.star.name());
+                s.put("level", c.info.level.level);
+                m.put("star", s);
+            }
+            cl.add(m);
+        }
+        r.put("combinations", cl);
+        Map<String,Object> st = new LinkedHashMap<>();
+        st.put("total", ta.cs.size()); st.put("auspiciousCount", ta.ac);
+        st.put("inauspiciousCount", ta.ic); st.put("dominantStar", ta.dom.isEmpty() ? "none" : ta.dom);
+        r.put("stats", st);
+        return r;
+    }
+
+    private void addC(List<ChartCombo> cs, Map<String,Integer> pos, String a, String b, int pri) {
+        Integer va = pos.get(a); Integer vb = pos.get(b);
+        if (va != null && vb != null) cs.add(new ChartCombo(a+"-"+b, va, vb, lookup(va, vb), pri));
+    }
+}