fix: keep western labels near their planets and chats on the current person
Cap planet captions at 30 degrees with a third ring, fall back only within the current person's sessions, and load self when the people catalog fails.
This commit is contained in:
@@ -1,322 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { useState, type Dispatch, FormEvent, SetStateAction } from "react";
|
||||
import { ChartProfileForm } from "@/components/chart-profile-form";
|
||||
import { deleteCloudChartProfile, saveCloudChartProfile, updateCloudChartProfile } from "@/lib/home-cloud-sync";
|
||||
import {
|
||||
formatChartUpdatedAt,
|
||||
missingOtherProfileStep,
|
||||
profileBirthTimeLabel,
|
||||
profileBirthTimeStatusLabel,
|
||||
profilePlaceLabel,
|
||||
readProfile,
|
||||
upsertSelfChart,
|
||||
} from "@/lib/home-profile";
|
||||
import {
|
||||
emptyProfile,
|
||||
timestamp,
|
||||
type Account,
|
||||
type ChartLibraryRecord,
|
||||
type Profile,
|
||||
type SynastryRelationshipType,
|
||||
type SynastryReportCard,
|
||||
} from "@/lib/home-types";
|
||||
import {
|
||||
CHART_LIBRARY_LIST_VIEW,
|
||||
synastryHistoryForPartner,
|
||||
type ChartLibraryView,
|
||||
} from "@/lib/chart-library-view";
|
||||
|
||||
export type ChartLibraryPanelProps = {
|
||||
readonly account: Account | null;
|
||||
readonly accountId: string | undefined;
|
||||
readonly chartLibrary: ChartLibraryRecord[];
|
||||
readonly setChartLibrary: Dispatch<SetStateAction<ChartLibraryRecord[]>>;
|
||||
readonly profile: Profile;
|
||||
readonly profileDraft: Profile;
|
||||
readonly setProfileDraft: Dispatch<SetStateAction<Profile>>;
|
||||
readonly setProfile: Dispatch<SetStateAction<Profile>>;
|
||||
readonly profileSaving: boolean;
|
||||
readonly profileNotice: string;
|
||||
readonly setProfileNotice: Dispatch<SetStateAction<string>>;
|
||||
readonly setAccountError: Dispatch<SetStateAction<string>>;
|
||||
readonly synastryRelationshipType: SynastryRelationshipType;
|
||||
readonly setSynastryRelationshipType: Dispatch<SetStateAction<SynastryRelationshipType>>;
|
||||
readonly synastryPendingId: string | null;
|
||||
readonly synastryReportCard: SynastryReportCard | null;
|
||||
readonly setSynastryReportCard: Dispatch<SetStateAction<SynastryReportCard | null>>;
|
||||
readonly synastryHistory: SynastryReportCard[];
|
||||
readonly saveProfile: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly draftSynastryQuestionFromChart: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => void;
|
||||
};
|
||||
|
||||
export function ChartLibraryPanel({
|
||||
account,
|
||||
accountId,
|
||||
chartLibrary,
|
||||
setChartLibrary,
|
||||
profile,
|
||||
profileDraft,
|
||||
setProfileDraft,
|
||||
profileSaving,
|
||||
profileNotice,
|
||||
setProfileNotice,
|
||||
setAccountError,
|
||||
synastryRelationshipType,
|
||||
setSynastryRelationshipType,
|
||||
synastryPendingId,
|
||||
synastryReportCard,
|
||||
setSynastryReportCard,
|
||||
synastryHistory,
|
||||
saveProfile,
|
||||
draftSynastryQuestionFromChart,
|
||||
}: ChartLibraryPanelProps) {
|
||||
const [view, setView] = useState<ChartLibraryView>(CHART_LIBRARY_LIST_VIEW);
|
||||
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
|
||||
|
||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||
|
||||
const selfCharts = chartLibrary.filter((record) => record.role === "self");
|
||||
const otherCharts = chartLibrary.filter((record) => record.role === "other");
|
||||
const selectedOther = view.kind === "other"
|
||||
? otherCharts.find((record) => record.id === view.id) ?? null
|
||||
: null;
|
||||
const partnerHistory = selectedOther
|
||||
? synastryHistoryForPartner(synastryHistory, {
|
||||
id: selectedOther.id,
|
||||
name: selectedOther.profile.name || "对方",
|
||||
})
|
||||
: [];
|
||||
|
||||
function goList() {
|
||||
setView(CHART_LIBRARY_LIST_VIEW);
|
||||
setEditingChartId(null);
|
||||
setOtherProfileDraft(emptyProfile);
|
||||
setProfileDraft(profile);
|
||||
}
|
||||
|
||||
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() };
|
||||
|
||||
if (missingOtherProfileStep(nextProfile)) {
|
||||
setAccountError("请补全其他星盘的称呼、出生时间和出生地点。");
|
||||
return;
|
||||
}
|
||||
if (!accountId) return;
|
||||
const record: ChartLibraryRecord = {
|
||||
id: editingChartId || globalThis.crypto.randomUUID(),
|
||||
role: "other",
|
||||
profile: nextProfile,
|
||||
relationship: "other",
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
try {
|
||||
const saved = editingChartId
|
||||
? await updateCloudChartProfile(record)
|
||||
: await saveCloudChartProfile(record);
|
||||
const selfProfile = account ? readProfile(account.profile) : profile;
|
||||
setChartLibrary((current) => {
|
||||
const others = editingChartId
|
||||
? current.map((item) => item.id === saved.id ? saved : item)
|
||||
: [...current, saved];
|
||||
return upsertSelfChart(others, selfProfile);
|
||||
});
|
||||
setOtherProfileDraft(emptyProfile);
|
||||
setEditingChartId(null);
|
||||
setAccountError("");
|
||||
setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
|
||||
setView(CHART_LIBRARY_LIST_VIEW);
|
||||
} catch {
|
||||
setProfileNotice("保存失败,请重试");
|
||||
setAccountError("");
|
||||
}
|
||||
}
|
||||
|
||||
function editOtherChart(record: ChartLibraryRecord) {
|
||||
if (record.role !== "other") return;
|
||||
setOtherProfileDraft(record.profile);
|
||||
setEditingChartId(record.id);
|
||||
setAccountError("");
|
||||
setProfileNotice("");
|
||||
setView({ kind: "other", id: record.id });
|
||||
}
|
||||
|
||||
async function deleteOtherChart(recordId: string) {
|
||||
if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return;
|
||||
try {
|
||||
await deleteCloudChartProfile(recordId);
|
||||
} catch {
|
||||
setProfileNotice("删除失败,请重试");
|
||||
setAccountError("");
|
||||
return;
|
||||
}
|
||||
setChartLibrary((current) => current.filter((record) => record.id !== recordId || record.role === "self"));
|
||||
setAccountError("");
|
||||
setProfileNotice("已从云端星盘库删除。");
|
||||
goList();
|
||||
}
|
||||
|
||||
function listRow(record: ChartLibraryRecord) {
|
||||
const isSelf = record.role === "self";
|
||||
return (
|
||||
<button
|
||||
className="chart-library-item"
|
||||
key={record.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSelf) {
|
||||
setProfileDraft(record.profile);
|
||||
setView({ kind: "self" });
|
||||
return;
|
||||
}
|
||||
editOtherChart(record);
|
||||
}}
|
||||
>
|
||||
<div className="chart-library-item-main">
|
||||
<div className="chart-library-item-title">
|
||||
<strong>{record.profile.name || "未命名"}</strong>
|
||||
<span className={`chart-role-badge${isSelf ? " is-self" : ""}`}>{isSelf ? "本人" : "其他人"}</span>
|
||||
|
||||
</div>
|
||||
<small>{effectiveBirthDate(record.profile) || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
|
||||
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
|
||||
</div>
|
||||
<ChevronRight className="chart-library-item-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "self") {
|
||||
return (
|
||||
<div className="chart-library-panel" aria-label="星盘资料管理">
|
||||
<button className="chart-library-back" type="button" onClick={goList}>← 星盘资料</button>
|
||||
<ChartProfileForm
|
||||
title="编辑本人星盘"
|
||||
description="这份资料会用于默认解盘与新对话。"
|
||||
value={profileDraft}
|
||||
onChange={setProfileDraft}
|
||||
nameInputId="self-profile-name"
|
||||
showAyanamsa
|
||||
onSubmit={saveProfile}
|
||||
onCancel={goList}
|
||||
cancelLabel="返回列表"
|
||||
submitLabel={profileSaving ? "保存中" : "保存本人资料"}
|
||||
submitDisabled={!account || profileSaving}
|
||||
notice={profileNotice}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "add" || (view.kind === "other" && selectedOther)) {
|
||||
const isAdd = view.kind === "add";
|
||||
return (
|
||||
<div className="chart-library-panel" aria-label="星盘资料管理">
|
||||
<button className="chart-library-back" type="button" onClick={goList}>← 星盘资料</button>
|
||||
<ChartProfileForm
|
||||
title={isAdd ? "添加其他人的星盘" : "编辑其他人的星盘"}
|
||||
description="用于合盘、亲友盘或客户盘。"
|
||||
value={otherProfileDraft}
|
||||
onChange={setOtherProfileDraft}
|
||||
nameInputId="other-profile-name"
|
||||
onSubmit={saveOtherChart}
|
||||
onCancel={goList}
|
||||
cancelLabel="返回列表"
|
||||
submitLabel={profileSaving ? "保存中" : editingChartId ? "保存修改" : "添加到星盘库"}
|
||||
submitDisabled={!account || profileSaving}
|
||||
notice={profileNotice}
|
||||
/>
|
||||
{!isAdd && selectedOther ? (
|
||||
<>
|
||||
<div className="chart-library-actions">
|
||||
|
||||
<button className="button-secondary" type="button" onClick={() => void deleteOtherChart(selectedOther.id)}>删除</button>
|
||||
</div>
|
||||
<section className="chart-library-group" aria-label="合盘">
|
||||
<div className="chart-library-group-heading">
|
||||
<div><b>合盘</b><small>把这份资料用于合盘分析</small></div>
|
||||
</div>
|
||||
<div className="chart-library-actions">
|
||||
<select aria-label={`${selectedOther.profile.name || "其他人"}的关系类型`} value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
|
||||
<option value="romance">婚恋</option>
|
||||
<option value="business">商业合作</option>
|
||||
<option value="family">亲友/家庭</option>
|
||||
<option value="general">其他关系</option>
|
||||
</select>
|
||||
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(selectedOther, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === selectedOther.id ? "正在计算合盘..." : "用于合盘"}</button>
|
||||
</div>
|
||||
{synastryReportCard && synastryHistoryForPartner([synastryReportCard], { id: selectedOther.id, name: selectedOther.profile.name || "对方" }).length > 0 && (
|
||||
<article className="synastry-report-card" aria-label="合盘结果摘要">
|
||||
<div>
|
||||
<span>合盘结果摘要</span>
|
||||
<strong>{synastryReportCard.partnerName}</strong>
|
||||
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
|
||||
</div>
|
||||
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
|
||||
<details>
|
||||
<summary>查看证据</summary>
|
||||
<ul>
|
||||
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
|
||||
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
<small>下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
|
||||
</details>
|
||||
</article>
|
||||
)}
|
||||
{partnerHistory.length > 0 && (
|
||||
<div className="synastry-history-list" aria-label="合盘历史">
|
||||
<b>合盘历史</b>
|
||||
{partnerHistory.slice(0, 5).map((item) => (
|
||||
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
|
||||
<span>{item.partnerName}</span>
|
||||
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chart-library-panel" aria-label="星盘资料管理">
|
||||
<div className="chart-library-group">
|
||||
<div className="chart-library-group-heading">
|
||||
<div><b>我的星盘</b><small>当前账号的默认资料</small></div>
|
||||
<span className="chart-library-count">{selfCharts.length}</span>
|
||||
</div>
|
||||
{selfCharts.map(listRow)}
|
||||
{selfCharts.length === 0 && <p className="empty-library-copy">请先在个人资料中补全你的出生资料。</p>}
|
||||
</div>
|
||||
<div className="chart-library-group">
|
||||
<div className="chart-library-group-heading">
|
||||
<div><b>其他人</b><small>亲友、伴侣或客户资料</small></div>
|
||||
<div className="chart-library-group-heading-actions">
|
||||
<span className="chart-library-count">{otherCharts.length}</span>
|
||||
<button
|
||||
className="button-secondary chart-library-add chart-library-add--heading"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOtherProfileDraft(emptyProfile);
|
||||
setEditingChartId(null);
|
||||
setAccountError("");
|
||||
setProfileNotice("");
|
||||
setView({ kind: "add" });
|
||||
}}
|
||||
>
|
||||
添加其他人
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{otherCharts.length === 0 && <p className="empty-library-copy">还没有其他星盘,先添加一份资料。</p>}
|
||||
{otherCharts.map(listRow)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,11 @@ export const WESTERN_R_OUTER = 252;
|
||||
export const WESTERN_R_ZODIAC = 228;
|
||||
export const WESTERN_R_PLANET = 188;
|
||||
export const WESTERN_R_PLANET_INNER = 136;
|
||||
// Inward of the two planet rings. House-number boxes stay obstacles, so a
|
||||
// label on this ring still cannot cover a house numeral.
|
||||
export const WESTERN_R_PLANET_CORE = 100;
|
||||
export const WESTERN_R_HOUSE = 112;
|
||||
export const WESTERN_LABEL_OFFSET_LIMIT_DEG = 30;
|
||||
export const WESTERN_R_NUMBER = 84;
|
||||
export const WESTERN_R_ASPECT = 62;
|
||||
export const WESTERN_R_ANGLE = 162;
|
||||
@@ -40,13 +44,35 @@ export type WesternLayoutBody = {
|
||||
caption: string;
|
||||
};
|
||||
|
||||
export type WesternLabelTier = 0 | 1 | 2;
|
||||
|
||||
export type WesternLayoutResult = {
|
||||
id: string;
|
||||
longitude: number;
|
||||
displayLongitude: number;
|
||||
tier: 0 | 1;
|
||||
tier: WesternLabelTier;
|
||||
};
|
||||
|
||||
export function westernPlanetRadius(tier: WesternLabelTier): number {
|
||||
if (tier === 0) return WESTERN_R_PLANET;
|
||||
if (tier === 1) return WESTERN_R_PLANET_INNER;
|
||||
return WESTERN_R_PLANET_CORE;
|
||||
}
|
||||
|
||||
export function westernAngularSeparation(from: number, to: number): number {
|
||||
const delta = Math.abs(normalizeDegrees(from) - normalizeDegrees(to));
|
||||
return Math.min(delta, 360 - delta);
|
||||
}
|
||||
|
||||
/** The caption only prints the degree inside a sign, so its sector must stay
|
||||
* on the planet's sign or an adjacent one. */
|
||||
export function westernLabelInAdjacentSign(longitude: number, displayLongitude: number): boolean {
|
||||
const planetSign = Math.floor(normalizeDegrees(longitude) / 30) % 12;
|
||||
const labelSign = Math.floor(normalizeDegrees(displayLongitude) / 30) % 12;
|
||||
const raw = Math.abs(labelSign - planetSign);
|
||||
return Math.min(raw, 12 - raw) <= 1;
|
||||
}
|
||||
|
||||
export type WesternLabelBox = {
|
||||
left: number;
|
||||
top: number;
|
||||
@@ -115,6 +141,22 @@ export function westernLabelBox(
|
||||
};
|
||||
}
|
||||
|
||||
/** Overlapping hit circles resolve to the nearer centre, not the later shape. */
|
||||
export function nearestWesternPlanetId(
|
||||
points: readonly { id: string; x: number; y: number }[],
|
||||
x: number,
|
||||
y: number,
|
||||
radius = WESTERN_HIT_RADIUS,
|
||||
): string | null {
|
||||
let best: { id: string; distance: number } | null = null;
|
||||
for (const point of points) {
|
||||
const distance = Math.hypot(point.x - x, point.y - y);
|
||||
if (distance > radius + 1e-6) continue;
|
||||
if (!best || distance < best.distance) best = { id: point.id, distance };
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export function westernLabelInsideViewBox(box: WesternLabelBox, size = WESTERN_SIZE): boolean {
|
||||
return box.left >= 0 && box.top >= 0 && box.right <= size && box.bottom <= size;
|
||||
}
|
||||
@@ -123,67 +165,23 @@ function boxesOverlap(a: WesternLabelBox, b: WesternLabelBox): boolean {
|
||||
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
}
|
||||
|
||||
function relax(original: number[]): number[] {
|
||||
const count = original.length;
|
||||
const pos = original.slice();
|
||||
const min = WESTERN_MIN_SEPARATION_DEG;
|
||||
for (let iter = 0; iter < 8; iter += 1) {
|
||||
for (let index = 1; index < count; index += 1) {
|
||||
const floor = pos[index - 1]! + min;
|
||||
if (pos[index]! < floor) pos[index] = floor;
|
||||
}
|
||||
const limit = pos[0]! + 360 - min;
|
||||
if (pos[count - 1]! > limit) {
|
||||
const overflow = pos[count - 1]! - limit;
|
||||
for (let index = 0; index < count; index += 1) pos[index] = pos[index]! - overflow;
|
||||
for (let index = count - 2; index >= 0; index -= 1) {
|
||||
const ceil = pos[index + 1]! - min;
|
||||
if (pos[index]! > ceil) pos[index] = ceil;
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const lo = index === 0 ? pos[count - 1]! - 360 + min : pos[index - 1]! + min;
|
||||
const hi = index === count - 1 ? pos[0]! + 360 - min : pos[index + 1]! - min;
|
||||
if (hi < lo) continue;
|
||||
pos[index] = Math.min(hi, Math.max(lo, original[index]!));
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
function planetPoint(displayLongitude: number, ascendantLongitude: number, tier: WesternLabelTier) {
|
||||
return westernPolar(westernPlanetRadius(tier), westernScreenAngle(displayLongitude, ascendantLongitude));
|
||||
}
|
||||
|
||||
function separateSorted(sorted: readonly number[]): number[] {
|
||||
const count = sorted.length;
|
||||
if (count === 0) return [];
|
||||
if (count === 1) return [normalizeDegrees(sorted[0]!)];
|
||||
let cut = 0;
|
||||
let largest = -1;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const gap = (sorted[(index + 1) % count]! - sorted[index]! + 360) % 360;
|
||||
if (gap > largest) {
|
||||
largest = gap;
|
||||
cut = (index + 1) % count;
|
||||
}
|
||||
}
|
||||
const order = Array.from({ length: count }, (_, step) => (cut + step) % count);
|
||||
const original: number[] = [];
|
||||
for (let step = 0; step < count; step += 1) {
|
||||
const longitude = sorted[order[step]!]!;
|
||||
if (step === 0) original.push(longitude);
|
||||
else {
|
||||
let next = longitude;
|
||||
while (next < original[step - 1]!) next += 360;
|
||||
original.push(next);
|
||||
}
|
||||
}
|
||||
const placed = relax(original);
|
||||
const display = sorted.slice();
|
||||
for (let step = 0; step < count; step += 1) display[order[step]!] = normalizeDegrees(placed[step]!);
|
||||
return display;
|
||||
}
|
||||
|
||||
function planetPoint(displayLongitude: number, ascendantLongitude: number, tier: 0 | 1) {
|
||||
const radius = tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
|
||||
return westernPolar(radius, westernScreenAngle(displayLongitude, ascendantLongitude));
|
||||
function labelFits(
|
||||
longitude: number,
|
||||
tier: WesternLabelTier,
|
||||
ascendantLongitude: number,
|
||||
caption: string,
|
||||
occupied: readonly WesternLabelBox[],
|
||||
centres: readonly { x: number; y: number; longitude: number; tier: WesternLabelTier }[],
|
||||
): { x: number; y: number } | null {
|
||||
const point = planetPoint(longitude, ascendantLongitude, tier);
|
||||
const box = westernLabelBox(point.x, point.y, caption);
|
||||
if (!westernLabelInsideViewBox(box) || occupied.some((other) => boxesOverlap(box, other))) return null;
|
||||
if (centres.some((other) => Math.hypot(point.x - other.x, point.y - other.y) <= WESTERN_HIT_RADIUS + 1)) return null;
|
||||
return point;
|
||||
}
|
||||
|
||||
export function layoutWesternHouseLabels(
|
||||
@@ -219,9 +217,11 @@ export function westernAngleLabels(ascendantLongitude: number, mcLongitude?: num
|
||||
}));
|
||||
}
|
||||
|
||||
/** Keep the axes fixed and place each caption in the nearest free two-ring slot.
|
||||
* The search checks text boxes as well as hit-circle centres, rather than
|
||||
* assuming alternating radii resolves every collision after angular relaxation.
|
||||
/** Keep the axes fixed. A caption may move at most 30° from its true longitude,
|
||||
* and only onto its own sign or an adjacent one. Two rings that cannot hold
|
||||
* the cluster use the inward third ring. Text boxes and hit-circle centres
|
||||
* both have to stay clear; the search backtracks instead of parking a label
|
||||
* on the far side of the wheel.
|
||||
*/
|
||||
export function layoutWesternLabels(
|
||||
planets: readonly WesternLayoutBody[],
|
||||
@@ -236,48 +236,109 @@ export function layoutWesternLabels(
|
||||
longitude: normalizeDegrees(planet.longitude),
|
||||
}));
|
||||
indexed.sort((a, b) => a.longitude - b.longitude || a.planet.id.localeCompare(b.planet.id));
|
||||
const display = separateSorted(indexed.map((item) => item.longitude));
|
||||
const occupied = [
|
||||
...fixedBoxes,
|
||||
...westernAngleLabels(ascendantLongitude, mcLongitude).map((angle) =>
|
||||
westernLabelBox(angle.x, angle.y, angle.caption)),
|
||||
];
|
||||
const centres: Array<{ x: number; y: number; longitude: number }> = [];
|
||||
const centres: Array<{ x: number; y: number; longitude: number; tier: WesternLabelTier }> = [];
|
||||
const results: WesternLayoutResult[] = new Array(planets.length);
|
||||
const candidates = [0, 1].flatMap((tier) =>
|
||||
Array.from({ length: 361 }, (_, step) => {
|
||||
const offset = Math.ceil(step / 2) * (step % 2 === 0 ? -1 : 1);
|
||||
return { tier: tier as 0 | 1, offset, cost: Math.abs(offset) + tier * 8 };
|
||||
}),
|
||||
).sort((a, b) => a.cost - b.cost || a.tier - b.tier || a.offset - b.offset);
|
||||
indexed.forEach((item, index) => {
|
||||
// Preserve every body even if a future caller exceeds the eleven-body wheel.
|
||||
const tiers: WesternLabelTier[] = [0, 1, 2];
|
||||
const candidateOffsets = [0];
|
||||
for (let step = 1; step <= WESTERN_LABEL_OFFSET_LIMIT_DEG; step += 1) {
|
||||
candidateOffsets.push(-step, step);
|
||||
}
|
||||
type Slot = { longitude: number; tier: WesternLabelTier; point: { x: number; y: number } };
|
||||
const chosen: Array<Slot | null> = indexed.map(() => null);
|
||||
let attempts = 0;
|
||||
const attemptLimit = 8000;
|
||||
|
||||
function slotsFor(item: (typeof indexed)[number]): Slot[] {
|
||||
const slots: Slot[] = [];
|
||||
for (const offset of candidateOffsets) {
|
||||
const longitude = normalizeDegrees(item.longitude + offset);
|
||||
if (!westernLabelInAdjacentSign(item.longitude, longitude)) continue;
|
||||
for (const tier of tiers) {
|
||||
const point = labelFits(longitude, tier, ascendantLongitude, item.planet.caption, occupied, centres);
|
||||
if (!point) continue;
|
||||
slots.push({ longitude, tier, point });
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
function search(remaining: number[]): boolean {
|
||||
if (remaining.length === 0) return true;
|
||||
if (attempts > attemptLimit) return false;
|
||||
let pickAt = 0;
|
||||
let pickSlots: Slot[] | null = null;
|
||||
for (let cursor = 0; cursor < remaining.length; cursor += 1) {
|
||||
const slots = slotsFor(indexed[remaining[cursor]!]!);
|
||||
attempts += 1;
|
||||
if (pickSlots === null || slots.length < pickSlots.length) {
|
||||
pickAt = cursor;
|
||||
pickSlots = slots;
|
||||
if (slots.length === 0) break;
|
||||
}
|
||||
}
|
||||
const slots = pickSlots ?? [];
|
||||
if (slots.length === 0) return false;
|
||||
const orderIndex = remaining[pickAt]!;
|
||||
const item = indexed[orderIndex]!;
|
||||
const nextRemaining = remaining.filter((_, index) => index !== pickAt);
|
||||
for (const slot of slots) {
|
||||
attempts += 1;
|
||||
chosen[orderIndex] = slot;
|
||||
occupied.push(westernLabelBox(slot.point.x, slot.point.y, item.planet.caption));
|
||||
centres.push({ ...slot.point, longitude: slot.longitude, tier: slot.tier });
|
||||
if (search(nextRemaining)) return true;
|
||||
occupied.pop();
|
||||
centres.pop();
|
||||
chosen[orderIndex] = null;
|
||||
if (attempts > attemptLimit) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const solved = search(indexed.map((_, index) => index));
|
||||
if (!solved) {
|
||||
for (let orderIndex = 0; orderIndex < indexed.length; orderIndex += 1) {
|
||||
const item = indexed[orderIndex]!;
|
||||
let slot: Slot | null = null;
|
||||
for (const offset of candidateOffsets) {
|
||||
const longitude = normalizeDegrees(item.longitude + offset);
|
||||
if (!westernLabelInAdjacentSign(item.longitude, longitude)) continue;
|
||||
for (const tier of tiers) {
|
||||
const point = planetPoint(longitude, ascendantLongitude, tier);
|
||||
const box = westernLabelBox(point.x, point.y, item.planet.caption);
|
||||
if (!westernLabelInsideViewBox(box)) continue;
|
||||
if (centres.some((other) => Math.hypot(point.x - other.x, point.y - other.y) <= WESTERN_HIT_RADIUS + 1)) continue;
|
||||
slot = { longitude, tier, point };
|
||||
break;
|
||||
}
|
||||
if (slot) break;
|
||||
}
|
||||
const fit = slot ?? {
|
||||
longitude: item.longitude,
|
||||
tier: 0 as WesternLabelTier,
|
||||
point: planetPoint(item.longitude, ascendantLongitude, 0),
|
||||
};
|
||||
chosen[orderIndex] = fit;
|
||||
centres.push({ ...fit.point, longitude: fit.longitude, tier: fit.tier });
|
||||
}
|
||||
}
|
||||
indexed.forEach((item, orderIndex) => {
|
||||
const fit = chosen[orderIndex] ?? {
|
||||
longitude: item.longitude,
|
||||
tier: 0 as WesternLabelTier,
|
||||
point: planetPoint(item.longitude, ascendantLongitude, 0),
|
||||
};
|
||||
results[item.index] = {
|
||||
id: item.planet.id,
|
||||
longitude: item.longitude,
|
||||
displayLongitude: display[index]!,
|
||||
tier: 0,
|
||||
displayLongitude: fit.longitude,
|
||||
tier: fit.tier,
|
||||
};
|
||||
for (const candidate of candidates) {
|
||||
const longitude = normalizeDegrees(display[index]! + candidate.offset);
|
||||
const point = planetPoint(longitude, ascendantLongitude, candidate.tier);
|
||||
const box = westernLabelBox(point.x, point.y, item.planet.caption);
|
||||
if (!westernLabelInsideViewBox(box) || occupied.some((other) => boxesOverlap(box, other))) continue;
|
||||
if (centres.some((other) => {
|
||||
const delta = Math.abs(longitude - other.longitude);
|
||||
return Math.min(delta, 360 - delta) < WESTERN_MIN_SEPARATION_DEG - 1e-6
|
||||
|| Math.hypot(point.x - other.x, point.y - other.y) <= WESTERN_HIT_RADIUS + 1;
|
||||
})) continue;
|
||||
results[item.index] = {
|
||||
id: item.planet.id,
|
||||
longitude: item.longitude,
|
||||
displayLongitude: longitude,
|
||||
tier: candidate.tier,
|
||||
};
|
||||
occupied.push(box);
|
||||
centres.push({ ...point, longitude });
|
||||
break;
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
import type { ChartViewWesternOk } from "@/lib/chart-view-contract";
|
||||
import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels";
|
||||
import {
|
||||
@@ -8,16 +10,16 @@ import {
|
||||
WESTERN_R_ASPECT,
|
||||
WESTERN_R_HOUSE,
|
||||
WESTERN_R_OUTER,
|
||||
WESTERN_R_PLANET,
|
||||
WESTERN_R_PLANET_INNER,
|
||||
WESTERN_R_ZODIAC,
|
||||
WESTERN_SIZE,
|
||||
houseMidLongitude,
|
||||
layoutWesternLabels,
|
||||
layoutWesternHouseLabels,
|
||||
nearestWesternPlanetId,
|
||||
westernAngleLabels,
|
||||
westernLabelBox,
|
||||
westernPlanetCaption,
|
||||
westernPlanetRadius,
|
||||
westernPolar,
|
||||
westernRingSlice,
|
||||
westernScreenAngle,
|
||||
@@ -106,6 +108,23 @@ export function WesternWheelSvg({
|
||||
fixedBoxes,
|
||||
);
|
||||
const placementById = new Map(placements.map((item) => [item.id, item]));
|
||||
const hitPoints = placements.map((placement) => {
|
||||
const display = westernPolar(
|
||||
westernPlanetRadius(placement.tier),
|
||||
westernScreenAngle(placement.displayLongitude, asc),
|
||||
);
|
||||
return { id: placement.id, x: display.x, y: display.y };
|
||||
});
|
||||
function selectNearest(event: MouseEvent<SVGElement>, fallbackId: string) {
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
const matrix = svg?.getScreenCTM();
|
||||
if (!svg || !matrix || !onSelectPlanet) return;
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = event.clientX;
|
||||
point.y = event.clientY;
|
||||
const local = point.matrixTransform(matrix.inverse());
|
||||
onSelectPlanet(nearestWesternPlanetId(hitPoints, local.x, local.y) ?? fallbackId);
|
||||
}
|
||||
const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude]));
|
||||
longitudeByLabel.set("上升", asc);
|
||||
if (tenth) longitudeByLabel.set("中天", tenth.longitude);
|
||||
@@ -206,7 +225,7 @@ export function WesternWheelSvg({
|
||||
{!skeleton && western.planets.map((planet) => {
|
||||
const placement = placementById.get(planet.id);
|
||||
if (!placement) return null;
|
||||
const radius = placement.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
|
||||
const radius = westernPlanetRadius(placement.tier);
|
||||
const display = westernPolar(radius, westernScreenAngle(placement.displayLongitude, asc));
|
||||
const truth = westernPolar(WESTERN_R_ZODIAC - 8, westernScreenAngle(placement.longitude, asc));
|
||||
const leaderEnd = pointToward(truth, display, 14);
|
||||
@@ -220,7 +239,7 @@ export function WesternWheelSvg({
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
aria-label={planet.label}
|
||||
onClick={() => onSelectPlanet?.(planet.id)}
|
||||
onClick={(event) => selectNearest(event, planet.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
@@ -251,7 +270,7 @@ export function WesternWheelSvg({
|
||||
{!skeleton && western.planets.map((planet) => {
|
||||
const placement = placementById.get(planet.id);
|
||||
if (!placement) return null;
|
||||
const radius = placement.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
|
||||
const radius = westernPlanetRadius(placement.tier);
|
||||
const display = westernPolar(radius, westernScreenAngle(placement.displayLongitude, asc));
|
||||
const selected = selectedLabel === planet.label;
|
||||
return (
|
||||
@@ -259,8 +278,10 @@ export function WesternWheelSvg({
|
||||
key={`${planet.id}-caption`}
|
||||
className={selected ? "chart-page-western-planet is-selected" : "chart-page-western-planet"}
|
||||
data-planet-caption={planet.id}
|
||||
data-longitude={fmt(placement.longitude)}
|
||||
data-display-longitude={fmt(placement.displayLongitude)}
|
||||
aria-hidden="true"
|
||||
onClick={() => onSelectPlanet?.(planet.id)}
|
||||
onClick={(event) => selectNearest(event, planet.id)}
|
||||
>
|
||||
<text
|
||||
x={display.x}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { SubjectSwitcher } from "@/components/subject-switcher";
|
||||
import { loadSubjectCatalog, setCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { loadSubjectCatalog, setCurrentSubject, subjectCatalogOthersUnavailable, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
|
||||
import type { ChatProfileCatalogStatus } from "@/lib/chat-profile-picker-model";
|
||||
import type { ChartLibraryRecord } from "@/lib/home-types";
|
||||
|
||||
@@ -17,7 +17,7 @@ export function CurrentSubjectSwitcher() {
|
||||
void loadSubjectCatalog(true)
|
||||
.then((next) => {
|
||||
setLibrary(next);
|
||||
setStatus("ready");
|
||||
setStatus(subjectCatalogOthersUnavailable() ? "degraded" : "ready");
|
||||
})
|
||||
.catch(() => setStatus("failed"));
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function CurrentSubjectSwitcher() {
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setLibrary(next);
|
||||
setStatus("ready");
|
||||
setStatus(subjectCatalogOthersUnavailable() ? "degraded" : "ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("failed");
|
||||
|
||||
@@ -95,6 +95,9 @@ export function SubjectSwitcher({
|
||||
<button className="chat-profile-picker-retry" type="button" onClick={onReload}>重试</button>
|
||||
</div>
|
||||
) : null}
|
||||
{status === "degraded" ? (
|
||||
<p className="chat-profile-picker-status" role="status">暂时读不到其他人</p>
|
||||
) : null}
|
||||
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
|
||||
{rows.length > 0 ? (
|
||||
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
|
||||
|
||||
Reference in New Issue
Block a user