Files
Jyotisha/frontend/src/lib/rectification-board-model.ts
T
Jesse_Chen e07d1bada4 fix(rectification): show natal houses and lagna changes in a live right rail
The board was live data sitting under chat, so each new event looked like nothing changed. Keep candidate cards in the transcript and group 换升 by minute for the side panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:24:05 +08:00

178 lines
6.0 KiB
TypeScript

import {
isWindowScanDisplayLayer,
WINDOW_SCAN_DISPLAY_LAYER_ORDER,
WINDOW_SCAN_SCORING_LAYER_ORDER,
type WindowScanLayer,
type WindowScanTransition,
} from "./rectification-agentic/v9/refinement-packet";
import {
workingRectificationHouseTable,
type RectificationCandidateResult,
type RectificationHouseTable,
} from "./rectification-candidate-result";
export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 832;
export type GroupedWindowMinute = Readonly<{
at: string;
scoringLayers: readonly WindowScanLayer[];
displayLayers: readonly WindowScanLayer[];
}>;
export type RectificationBoardDiff = Readonly<{
houseNumbers: readonly number[];
lagnaChanged: boolean;
timeChanged: boolean;
transitionMinutes: readonly string[];
liveMessage: string;
}>;
const SCORING_RANK = new Map<WindowScanLayer, number>(
WINDOW_SCAN_SCORING_LAYER_ORDER.map((layer, index) => [layer, index]),
);
const DISPLAY_RANK = new Map<WindowScanLayer, number>(
WINDOW_SCAN_DISPLAY_LAYER_ORDER.map((layer, index) => [layer, index]),
);
const EMPTY_DIFF: RectificationBoardDiff = {
houseNumbers: [],
lagnaChanged: false,
timeChanged: false,
transitionMinutes: [],
liveMessage: "",
};
function sortLayers(
layers: readonly WindowScanLayer[],
rank: ReadonlyMap<WindowScanLayer, number>,
): readonly WindowScanLayer[] {
return [...layers].sort((left, right) => (rank.get(left) ?? 99) - (rank.get(right) ?? 99));
}
export function groupWindowTransitions(
transitions: readonly WindowScanTransition[],
): readonly GroupedWindowMinute[] {
const byMinute = new Map<string, { scoring: Set<WindowScanLayer>; display: Set<WindowScanLayer> }>();
for (const item of transitions) {
const bucket = byMinute.get(item.at) ?? { scoring: new Set<WindowScanLayer>(), display: new Set<WindowScanLayer>() };
if (isWindowScanDisplayLayer(item.layer)) bucket.display.add(item.layer);
else bucket.scoring.add(item.layer);
byMinute.set(item.at, bucket);
}
return [...byMinute.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([at, bucket]) => ({
at,
scoringLayers: sortLayers([...bucket.scoring], SCORING_RANK),
displayLayers: sortLayers([...bucket.display], DISPLAY_RANK),
}));
}
export function changedHouseNumbers(
previous: RectificationHouseTable | null,
next: RectificationHouseTable | null,
): readonly number[] {
if (!previous || !next) return [];
const changed: number[] = [];
for (let index = 0; index < 12; index += 1) {
const before = previous.houses[index];
const after = next.houses[index];
if (!before || !after) continue;
if (before.sign !== after.sign || before.occupants.join("\0") !== after.occupants.join("\0")) {
changed.push(after.house);
}
}
return changed;
}
function groupedMinuteKey(row: GroupedWindowMinute): string {
return `${row.at}|${row.scoringLayers.join(",")}|${row.displayLayers.join(",")}`;
}
export function changedTransitionMinutes(
previous: readonly WindowScanTransition[],
next: readonly WindowScanTransition[],
): readonly string[] {
const before = new Map(groupWindowTransitions(previous).map((row) => [row.at, groupedMinuteKey(row)]));
const changed: string[] = [];
for (const row of groupWindowTransitions(next)) {
if (before.get(row.at) !== groupedMinuteKey(row)) changed.push(row.at);
}
return changed;
}
function joinHouses(houses: readonly number[]): string {
return houses.map((house) => `${house}`).join("、");
}
export function rectificationBoardLiveMessage(input: Readonly<{
previous: RectificationCandidateResult | null;
next: RectificationCandidateResult | null;
houseNumbers: readonly number[];
lagnaChanged: boolean;
timeChanged: boolean;
transitionMinutes: readonly string[];
}>): string {
const nextTable = input.next ? workingRectificationHouseTable(input.next) : null;
if (!nextTable) return "";
if (!input.previous) {
return `当前本命宫位已按 ${nextTable.time} 排出,上升${nextTable.lagna}`;
}
const parts: string[] = [];
if (input.timeChanged || input.lagnaChanged) {
parts.push(`本命宫位已按 ${nextTable.time} 重算(上升 ${nextTable.lagna})。`);
}
if (input.houseNumbers.length > 0 && !(input.timeChanged && input.houseNumbers.length === 12)) {
parts.push(`${joinHouses(input.houseNumbers)}已更新。`);
}
if (input.transitionMinutes.length > 0) {
parts.push("换升时刻已更新。");
}
return parts.join("");
}
export function diffRectificationBoard(
previous: RectificationCandidateResult | null,
next: RectificationCandidateResult | null,
): RectificationBoardDiff {
if (!next) return EMPTY_DIFF;
const previousTable = previous ? workingRectificationHouseTable(previous) : null;
const nextTable = workingRectificationHouseTable(next);
const houseNumbers = changedHouseNumbers(previousTable, nextTable);
const lagnaChanged = Boolean(previousTable && nextTable && previousTable.lagna !== nextTable.lagna);
const timeChanged = Boolean(previousTable && nextTable && previousTable.time !== nextTable.time);
const transitionMinutes = previous
? changedTransitionMinutes(previous.windowTransitions, next.windowTransitions)
: [];
return {
houseNumbers,
lagnaChanged,
timeChanged,
transitionMinutes,
liveMessage: rectificationBoardLiveMessage({
previous,
next,
houseNumbers,
lagnaChanged,
timeChanged,
transitionMinutes,
}),
};
}
export function rectificationBoardPeekCopy(result: RectificationCandidateResult | null): Readonly<{
title: string;
detail: string | null;
}> {
const table = result ? workingRectificationHouseTable(result) : null;
if (!table) {
return { title: "当前盘面", detail: "补充经历后会在这里更新" };
}
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
}
export function workingRectificationTime(result: RectificationCandidateResult | null): string | null {
if (!result) return null;
return result.selectedTime ?? result.representativeTime ?? workingRectificationHouseTable(result)?.time ?? null;
}