fix: keep western labels near their planets and chats on the current person
Independent Staging Quality Gate / validate (push) Failing after 6m46s
Independent Staging Quality Gate / publish (push) Skipped

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:
jesse-ux
2026-09-25 23:02:17 +08:00
parent b8712dad50
commit d08ffdc0a6
28 changed files with 538 additions and 545 deletions
@@ -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="当前星盘">