Files
Jyotisha/frontend/src/lib/rectification-board-model.ts
T
Jesse_Chen 9670b661c4 fix(rectification): show mobile natal board as a covering bottom sheet
BUG-330: the compact board sat in the same layer as the composer, so peek copy overlapped the sheet title. Overlay only below 768px; desktop stays a side column.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 21:35:48 +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 = 768;
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;
}