use crate::core::calendar::lunar_month_has_thirtieth;
use crate::core::labels::{chinese_date, zh_cn};
use crate::core::model::bureau::FiveElementBureau;
use crate::core::model::calendar::{CalendarKind, Gender};
use crate::core::model::chart::{
Chart, DecadalFrame, DecadalPeriod, DecorativeStarFamily, DecorativeStarPlacement,
HoroscopeChart, MutagenActivation, PALACE_COUNT, Palace, PalaceGridPosition, PalaceName,
StarPlacement, TemporalLayer, TemporalPalaceName, VISUAL_BRANCH_ORDER, build_age_period,
build_decadal_frame, palace_grid_position,
};
use crate::core::model::master::{body_master, soul_master};
use crate::core::model::star::mutagen::Scope;
use crate::core::model::star::{Brightness, StarCategory, StarKind, StarName, mutagen::Mutagen};
use crate::core::model::zodiac::{WesternZodiac, western_zodiac};
use lunar_lite::{EarthlyBranch, FourPillars, HeavenlyStem, StemBranch};
use serde::{Deserialize, Serialize};
const SELECTOR_ORDER: [Scope; 7] = [
Scope::Natal,
Scope::Decadal,
Scope::Age,
Scope::Yearly,
Scope::Monthly,
Scope::Daily,
Scope::Hourly,
];
const MONTH_LABELS: [&str; PALACE_COUNT] = [
"正月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "冬月", "腊月",
];
const DAY_LABELS: [[&str; 10]; 3] = [
[
"初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
],
[
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
],
[
"廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十",
],
];
const HOUR_LABELS: [&str; PALACE_COUNT] = [
"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥",
];
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartViewSnapshot {
pub center: StaticChartCenterView,
pub palaces: Vec<StaticPalaceView>,
pub temporal_panel: StaticTemporalPanelView,
pub selectors: Vec<StaticChartSelectorView>,
pub active_scopes: Vec<Scope>,
pub highlights: Vec<HighlightView>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct LunarDateView {
pub year: i32,
pub month: u8,
pub day: u8,
pub is_leap_month: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartCenterView {
pub gender: Gender,
pub birth_year_stem: HeavenlyStem,
pub birth_year_stem_zh: String,
pub birth_year_branch: EarthlyBranch,
pub birth_year_branch_zh: String,
#[serde(default)]
pub four_pillars: Option<StaticFourPillarsView>,
pub five_element_bureau: Option<FiveElementBureau>,
#[serde(default)]
pub five_element_bureau_zh: Option<String>,
#[serde(default)]
pub birth_solar_label: String,
#[serde(default)]
pub birth_lunar_label: String,
#[serde(default)]
pub birth_time_label: String,
#[serde(default)]
pub zodiac_zh: String,
#[serde(default)]
pub constellation_zh: String,
#[serde(default)]
pub soul_master_zh: Option<String>,
#[serde(default)]
pub body_master_zh: Option<String>,
#[serde(default)]
pub nominal_age_label: Option<String>,
#[serde(default)]
pub temporal_lunar_label: Option<String>,
#[serde(default)]
pub temporal_solar_label: Option<String>,
pub life_palace_branch: Option<EarthlyBranch>,
pub life_palace_branch_zh: Option<String>,
pub body_palace_branch: Option<EarthlyBranch>,
pub body_palace_branch_zh: Option<String>,
#[serde(default)]
pub birth_lunar_date: Option<LunarDateView>,
#[serde(default)]
pub birth_time_index: Option<u8>,
#[serde(default)]
pub western_zodiac: Option<WesternZodiac>,
#[serde(default)]
pub soul_master: Option<StarName>,
#[serde(default)]
pub body_master: Option<StarName>,
#[serde(default)]
pub nominal_age: Option<u16>,
#[serde(default)]
pub temporal_lunar_date: Option<LunarDateView>,
#[serde(default)]
pub temporal_lunar_year: Option<i32>,
#[serde(default)]
pub small_limit_age: Option<u16>,
#[serde(default)]
pub small_limit_branch: Option<EarthlyBranch>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticFourPillarsView {
pub yearly: StemBranch,
pub yearly_zh: String,
pub monthly: StemBranch,
pub monthly_zh: String,
pub daily: StemBranch,
pub daily_zh: String,
pub hourly: StemBranch,
pub hourly_zh: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticTemporalPanelView {
#[serde(default)]
pub pre_decadal_cell: StaticPreDecadalCellView,
pub decadal_cells: Vec<StaticDecadalCellView>,
pub yearly_age_cells: Vec<StaticYearlyAgeCellView>,
pub month_cells: Vec<StaticNavigationCellView>,
pub day_rows: Vec<Vec<StaticNavigationCellView>>,
pub hour_cells: Vec<StaticNavigationCellView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct StaticPreDecadalCellView {
pub enabled: bool,
#[serde(default)]
pub selected: bool,
pub label_zh: String,
pub age_range_zh: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticDecadalCellView {
pub enabled: bool,
#[serde(default)]
pub selected: bool,
pub age_range_zh: Option<String>,
pub limit_label_zh: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticYearlyAgeCellView {
pub enabled: bool,
#[serde(default)]
pub selected: bool,
pub year_label: Option<String>,
pub stem_branch_age_zh: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticNavigationCellView {
pub label_zh: String,
pub enabled: bool,
#[serde(default)]
pub selected: bool,
}
impl StaticFourPillarsView {
fn from_four_pillars(pillars: FourPillars) -> Self {
Self {
yearly: pillars.yearly,
yearly_zh: zh_cn::stem_branch_zh(pillars.yearly),
monthly: pillars.monthly,
monthly_zh: zh_cn::stem_branch_zh(pillars.monthly),
daily: pillars.daily,
daily_zh: zh_cn::stem_branch_zh(pillars.daily),
hourly: pillars.hourly,
hourly_zh: zh_cn::stem_branch_zh(pillars.hourly),
}
}
}
impl StaticTemporalPanelView {
pub(crate) fn from_selection(
natal: &Chart,
selection: StaticTemporalNavigationSelection,
) -> Self {
let frame = build_decadal_frame(natal).ok();
let dec_idx = selection.decadal_index();
let year_idx = selection.year_index();
let month_idx = selection.month_index();
let day_idx = selection.day_index();
let hour_idx = selection.hour_index();
let pre_decadal_cell = pre_decadal_cell(
natal,
matches!(selection, StaticTemporalNavigationSelection::PreDecadal),
);
let decadal_cells = decadal_cells(frame.as_ref(), dec_idx);
let selected_period = match (frame.as_ref(), dec_idx) {
(Some(frame), Some(di)) => frame.periods().get(di),
_ => None,
};
let yearly_age_cells = match selected_period {
Some(period) => yearly_age_cells(natal, period, year_idx),
None => disabled_yearly_age_cells(),
};
let selected_lunar_year = match (selected_period, year_idx) {
(Some(period), Some(yi)) => Some(lunar_year_of(natal, period, yi)),
_ => None,
};
let month_cells = labeled_cells(&MONTH_LABELS, selected_lunar_year.is_some(), month_idx);
let selected_lunar_month = month_idx.map(|m| m + 1);
let day_rows = day_rows(selected_lunar_year, selected_lunar_month, day_idx);
let selected_hour_cell = hour_idx.map(|index| if index == 12 { 0 } else { index });
let hour_cells = labeled_cells(&HOUR_LABELS, day_idx.is_some(), selected_hour_cell);
Self {
pre_decadal_cell,
decadal_cells,
yearly_age_cells,
month_cells,
day_rows,
hour_cells,
}
}
fn from_chart(chart: &Chart) -> Self {
Self::from_selection(chart, StaticTemporalNavigationSelection::Natal)
}
fn from_horoscope_chart(chart: &HoroscopeChart) -> Self {
Self::from_chart(chart.natal())
}
}
fn lunar_year_of(natal: &Chart, period: &DecadalPeriod, year_index: u8) -> i32 {
let nominal_age = period.start_age() as i32 + year_index as i32;
natal.birth_context().date().year() + nominal_age - 1
}
fn pre_decadal_cell(chart: &Chart, selected: bool) -> StaticPreDecadalCellView {
const LABEL: &str = "限前";
match build_decadal_frame(chart) {
Ok(frame) => {
let first_start = frame.periods().first().map(|period| period.start_age());
let age_range_zh = match first_start {
Some(start) if start > 1 => Some(format!("1-{}", start - 1)),
_ => None,
};
StaticPreDecadalCellView {
enabled: true,
selected,
label_zh: LABEL.to_owned(),
age_range_zh,
}
}
Err(_) => StaticPreDecadalCellView {
enabled: false,
selected,
label_zh: LABEL.to_owned(),
age_range_zh: None,
},
}
}
fn decadal_cells(
frame: Option<&DecadalFrame>,
selected_index: Option<usize>,
) -> Vec<StaticDecadalCellView> {
match frame {
Some(frame) => frame
.periods()
.iter()
.enumerate()
.map(|(index, period)| StaticDecadalCellView {
enabled: true,
selected: selected_index == Some(index),
age_range_zh: Some(format!("{}-{}", period.start_age(), period.end_age())),
limit_label_zh: Some(format!("{}限", zh_cn::stem_branch_zh(period.stem_branch()))),
})
.collect(),
None => (0..PALACE_COUNT)
.map(|_| StaticDecadalCellView {
enabled: false,
selected: false,
age_range_zh: None,
limit_label_zh: None,
})
.collect(),
}
}
fn disabled_yearly_age_cells() -> Vec<StaticYearlyAgeCellView> {
(0..PALACE_COUNT)
.map(|_| StaticYearlyAgeCellView {
enabled: false,
selected: false,
year_label: None,
stem_branch_age_zh: None,
})
.collect()
}
fn yearly_age_cells(
natal: &Chart,
period: &DecadalPeriod,
selected_index: Option<u8>,
) -> Vec<StaticYearlyAgeCellView> {
let mut cells: Vec<StaticYearlyAgeCellView> = (0u8..10)
.map(|year_index| {
let nominal_age = period.start_age() + year_index;
let lunar_year = lunar_year_of(natal, period, year_index);
let stem_branch = StemBranch::from_lunar_year(lunar_year);
StaticYearlyAgeCellView {
enabled: true,
selected: selected_index == Some(year_index),
year_label: Some(lunar_year.to_string()),
stem_branch_age_zh: Some(format!(
"{}{}",
zh_cn::stem_branch_zh(stem_branch),
nominal_age
)),
}
})
.collect();
cells.extend(
disabled_yearly_age_cells()
.into_iter()
.take(PALACE_COUNT - cells.len()),
);
cells
}
fn day_rows(
selected_lunar_year: Option<i32>,
selected_lunar_month: Option<u8>,
selected_index: Option<u8>,
) -> Vec<Vec<StaticNavigationCellView>> {
let enabled = selected_lunar_month.is_some();
let has_thirtieth = match (selected_lunar_year, selected_lunar_month) {
(Some(year), Some(month)) => lunar_month_has_thirtieth(year, month),
_ => false,
};
DAY_LABELS
.iter()
.enumerate()
.map(|(row, labels)| {
labels
.iter()
.enumerate()
.map(|(col, label)| {
let day_index = (row * labels.len() + col) as u8;
let cell_enabled = enabled && (day_index != 29 || has_thirtieth);
StaticNavigationCellView {
label_zh: (*label).to_owned(),
enabled: cell_enabled,
selected: cell_enabled && selected_index == Some(day_index),
}
})
.collect()
})
.collect()
}
fn labeled_cells(
labels: &[&str],
enabled: bool,
selected_index: Option<u8>,
) -> Vec<StaticNavigationCellView> {
labels
.iter()
.enumerate()
.map(|(index, label)| StaticNavigationCellView {
label_zh: (*label).to_owned(),
enabled,
selected: enabled && selected_index == Some(index as u8),
})
.collect()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct StaticSurroundPalacesView {
pub opposite: EarthlyBranch,
pub wealth: EarthlyBranch,
pub career: EarthlyBranch,
}
impl StaticSurroundPalacesView {
pub fn for_branch(branch: EarthlyBranch) -> Self {
Self {
opposite: branch.offset(6),
wealth: branch.offset(8),
career: branch.offset(4),
}
}
pub fn branches(&self) -> [EarthlyBranch; 3] {
[self.opposite, self.wealth, self.career]
}
pub fn involves(&self, branch: EarthlyBranch) -> bool {
self.branches().contains(&branch)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct StaticPalaceLimitView {
pub decadal_age_range_zh: Option<String>,
#[serde(default)]
pub small_limit_ages_zh: Vec<String>,
#[serde(default)]
pub small_limit_ages: Vec<u16>,
#[serde(default)]
pub is_active_decadal: bool,
#[serde(default)]
pub is_active_small_limit: bool,
#[serde(default)]
pub active_small_limit_age: Option<u16>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticPalaceView {
pub branch: EarthlyBranch,
pub branch_zh: String,
pub grid_position: PalaceGridPosition,
pub surround: StaticSurroundPalacesView,
#[serde(default)]
pub limit: StaticPalaceLimitView,
pub name: PalaceName,
pub name_zh: String,
pub stem: HeavenlyStem,
pub stem_zh: String,
pub roles: Vec<StaticPalaceRole>,
pub major_stars: Vec<StaticTypedStarView>,
pub minor_stars: Vec<StaticTypedStarView>,
pub adjective_stars: Vec<StaticTypedStarView>,
pub other_typed_stars: Vec<StaticTypedStarView>,
pub decorative_stars: Vec<StaticDecorativeStarView>,
pub overlays: Vec<StaticTemporalOverlayView>,
pub highlights: Vec<HighlightView>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "palace_name", rename_all = "snake_case")]
pub enum StaticPalaceRole {
NatalPalace(PalaceName),
BodyPalace,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticTypedStarView {
pub name: StarName,
pub name_zh: String,
pub category: StarCategory,
pub category_zh: String,
pub kind: StarKind,
pub kind_zh: String,
pub brightness: Brightness,
pub brightness_zh: String,
pub mutagen: Option<Mutagen>,
pub mutagen_zh: Option<String>,
}
impl StaticTypedStarView {
fn from_star_placement(placement: &StarPlacement) -> Self {
Self {
name: placement.name(),
name_zh: zh_cn::star_name_zh(placement.name()).to_owned(),
category: placement.category(),
category_zh: zh_cn::star_category_zh(placement.category()).to_owned(),
kind: placement.kind(),
kind_zh: zh_cn::star_kind_zh(placement.kind()).to_owned(),
brightness: placement.brightness(),
brightness_zh: zh_cn::brightness_zh(placement.brightness()).to_owned(),
mutagen: placement.mutagen(),
mutagen_zh: placement
.mutagen()
.map(|mutagen| zh_cn::mutagen_zh(mutagen).to_owned()),
}
}
fn order_key(&self) -> (StarKind, StarName, Brightness, Option<Mutagen>) {
(self.kind, self.name, self.brightness, self.mutagen)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticDecorativeStarView {
pub name: StarName,
pub name_zh: String,
pub family: DecorativeStarFamily,
pub family_zh: String,
}
impl StaticDecorativeStarView {
fn from_decorative_star_placement(placement: &DecorativeStarPlacement) -> Self {
Self {
name: placement.name(),
name_zh: zh_cn::star_name_zh(placement.name()).to_owned(),
family: placement.family(),
family_zh: zh_cn::decorative_star_family_zh(placement.family()).to_owned(),
}
}
fn order_key(&self) -> (DecorativeStarFamily, StarName) {
(self.family, self.name)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartSelectorView {
pub scope: Scope,
pub label_zh: String,
pub enabled: bool,
pub selected: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticTemporalOverlayView {
pub scope: Scope,
#[serde(default)]
pub period_label_zh: Option<String>,
#[serde(default)]
pub period_stem: Option<HeavenlyStem>,
pub temporal_palace_name: Option<PalaceName>,
pub temporal_palace_name_zh: Option<String>,
pub typed_stars: Vec<StaticTypedStarView>,
pub decorative_stars: Vec<StaticDecorativeStarView>,
pub mutagens: Vec<StaticOverlayMutagenView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticOverlayMutagenView {
pub star: StarName,
pub star_zh: String,
pub mutagen: Mutagen,
pub mutagen_zh: String,
}
impl StaticOverlayMutagenView {
fn from_activation(activation: &MutagenActivation) -> Self {
Self {
star: activation.target_star(),
star_zh: zh_cn::star_name_zh(activation.target_star()).to_owned(),
mutagen: activation.mutagen(),
mutagen_zh: zh_cn::mutagen_zh(activation.mutagen()).to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct HighlightView {
pub id: String,
pub label_zh: Option<String>,
pub scope: Option<Scope>,
pub involved_palaces: Vec<EarthlyBranch>,
pub involved_stars: Vec<StarName>,
pub involved_mutagens: Vec<Mutagen>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartViewRequest {
pub visible_scopes: Vec<Scope>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum StaticTemporalNavigationSelection {
#[default]
Natal,
PreDecadal,
Decadal {
decadal_index: usize,
},
Yearly {
decadal_index: usize,
year_index: u8,
},
Monthly {
decadal_index: usize,
year_index: u8,
month_index: u8,
},
Daily {
decadal_index: usize,
year_index: u8,
month_index: u8,
day_index: u8,
},
Hourly {
decadal_index: usize,
year_index: u8,
month_index: u8,
day_index: u8,
hour_index: u8,
},
}
impl StaticTemporalNavigationSelection {
pub const fn decadal_index(&self) -> Option<usize> {
match self {
Self::Natal | Self::PreDecadal => None,
Self::Decadal { decadal_index }
| Self::Yearly { decadal_index, .. }
| Self::Monthly { decadal_index, .. }
| Self::Daily { decadal_index, .. }
| Self::Hourly { decadal_index, .. } => Some(*decadal_index),
}
}
pub const fn year_index(&self) -> Option<u8> {
match self {
Self::Yearly { year_index, .. }
| Self::Monthly { year_index, .. }
| Self::Daily { year_index, .. }
| Self::Hourly { year_index, .. } => Some(*year_index),
_ => None,
}
}
pub const fn month_index(&self) -> Option<u8> {
match self {
Self::Monthly { month_index, .. }
| Self::Daily { month_index, .. }
| Self::Hourly { month_index, .. } => Some(*month_index),
_ => None,
}
}
pub const fn day_index(&self) -> Option<u8> {
match self {
Self::Daily { day_index, .. } | Self::Hourly { day_index, .. } => Some(*day_index),
_ => None,
}
}
pub const fn hour_index(&self) -> Option<u8> {
match self {
Self::Hourly { hour_index, .. } => Some(*hour_index),
_ => None,
}
}
}
impl StaticChartViewSnapshot {
pub fn from_chart(chart: &Chart) -> Self {
let palaces = build_palaces(chart, &[]);
let present = [Scope::Natal];
let selected = [Scope::Natal];
Self {
center: StaticChartCenterView::from_chart(chart),
palaces,
temporal_panel: StaticTemporalPanelView::from_chart(chart),
selectors: build_selectors(&present, &selected),
active_scopes: active_scopes(&selected),
highlights: Vec::new(),
}
}
pub fn from_horoscope_chart(chart: &HoroscopeChart) -> Self {
let mut visible_scopes = vec![Scope::Natal];
visible_scopes.extend(
present_scopes(chart)
.into_iter()
.filter(|s| *s != Scope::Natal),
);
Self::from_horoscope_chart_with(chart, &StaticChartViewRequest { visible_scopes })
}
pub fn from_horoscope_chart_with(
chart: &HoroscopeChart,
request: &StaticChartViewRequest,
) -> Self {
let natal = chart.natal();
let present = present_scopes(chart);
let selected: Vec<Scope> = SELECTOR_ORDER
.into_iter()
.filter(|scope| {
*scope == Scope::Natal
|| (present.contains(scope) && request.visible_scopes.contains(scope))
})
.collect();
let overlay_layers: Vec<&TemporalLayer> = SELECTOR_ORDER
.into_iter()
.filter(|scope| *scope != Scope::Natal && selected.contains(scope))
.flat_map(|scope| {
chart
.layers()
.iter()
.filter(move |layer| layer.scope() == scope)
})
.collect();
Self {
center: StaticChartCenterView::from_chart(natal),
palaces: build_palaces(natal, &overlay_layers),
temporal_panel: StaticTemporalPanelView::from_horoscope_chart(chart),
selectors: build_selectors(&present, &selected),
active_scopes: active_scopes(&selected),
highlights: Vec::new(),
}
}
}
impl StaticChartCenterView {
fn from_chart(chart: &Chart) -> Self {
let life_palace_branch = chart.life_palace().map(Palace::branch);
let body_palace_branch = chart.body_palace_branch();
let birth_year_branch = chart.birth_year().branch();
let time_index = chart
.birth_context()
.birth_time_variant()
.iztro_time_index();
let (birth_solar_label, birth_lunar_label, constellation_zh) = natal_date_labels(chart);
let (birth_lunar_date, western_zodiac_sign) = natal_typed_dates(chart);
Self {
gender: chart.birth_context().gender(),
birth_year_stem: chart.birth_year().stem(),
birth_year_stem_zh: zh_cn::heavenly_stem_zh(chart.birth_year().stem()).to_owned(),
birth_year_branch,
birth_year_branch_zh: zh_cn::earthly_branch_zh(birth_year_branch).to_owned(),
four_pillars: chart
.four_pillars()
.copied()
.map(StaticFourPillarsView::from_four_pillars),
five_element_bureau: chart.five_element_bureau(),
five_element_bureau_zh: chart
.five_element_bureau()
.map(|bureau| zh_cn::five_element_bureau_zh(bureau).to_owned()),
birth_solar_label,
birth_lunar_label,
birth_time_label: chinese_date::birth_time_label(time_index),
zodiac_zh: zh_cn::zodiac_animal_zh(birth_year_branch).to_owned(),
constellation_zh,
soul_master_zh: life_palace_branch
.map(|branch| zh_cn::star_name_zh(soul_master(branch)).to_owned()),
body_master_zh: Some(zh_cn::star_name_zh(body_master(birth_year_branch)).to_owned()),
nominal_age_label: None,
temporal_lunar_label: None,
temporal_solar_label: None,
life_palace_branch,
life_palace_branch_zh: life_palace_branch
.map(|branch| zh_cn::earthly_branch_zh(branch).to_owned()),
body_palace_branch,
body_palace_branch_zh: body_palace_branch
.map(|branch| zh_cn::earthly_branch_zh(branch).to_owned()),
birth_lunar_date,
birth_time_index: Some(time_index),
western_zodiac: western_zodiac_sign,
soul_master: life_palace_branch.map(soul_master),
body_master: Some(body_master(birth_year_branch)),
nominal_age: None,
temporal_lunar_date: None,
temporal_lunar_year: None,
small_limit_age: None,
small_limit_branch: None,
}
}
}
fn natal_typed_dates(chart: &Chart) -> (Option<LunarDateView>, Option<WesternZodiac>) {
if let Some(facts) = chart.natal_date_facts() {
let solar = facts.solar();
let lunar = facts.lunar();
return (
Some(LunarDateView {
year: lunar.year(),
month: lunar.month(),
day: lunar.day(),
is_leap_month: lunar.is_leap_month(),
}),
western_zodiac(solar.month(), solar.day()),
);
}
let date = chart.birth_context().date();
match date.kind() {
CalendarKind::Solar => (None, western_zodiac(date.month(), date.day())),
CalendarKind::Lunar => (
Some(LunarDateView {
year: date.year(),
month: date.month(),
day: date.day(),
is_leap_month: false,
}),
None,
),
}
}
fn natal_date_labels(chart: &Chart) -> (String, String, String) {
if let Some(facts) = chart.natal_date_facts() {
let solar = facts.solar();
let lunar = facts.lunar();
return (
chinese_date::solar_date_label_padded(solar.year(), solar.month(), solar.day()),
chinese_date::lunar_date_label(
lunar.year(),
lunar.month(),
lunar.day(),
lunar.is_leap_month(),
),
chinese_date::constellation_zh(solar.month(), solar.day()).to_owned(),
);
}
let date = chart.birth_context().date();
match date.kind() {
CalendarKind::Solar => (
chinese_date::solar_date_label_padded(date.year(), date.month(), date.day()),
String::new(),
chinese_date::constellation_zh(date.month(), date.day()).to_owned(),
),
CalendarKind::Lunar => (
String::new(),
chinese_date::lunar_date_label(date.year(), date.month(), date.day(), false),
String::new(),
),
}
}
const SMALL_LIMIT_MAX_AGE: u8 = 120;
struct PalaceLimits {
decadal: Vec<(EarthlyBranch, u8, u8)>,
small: Vec<(EarthlyBranch, Vec<u8>)>,
}
impl PalaceLimits {
fn for_chart(chart: &Chart) -> Self {
let mut decadal = Vec::new();
if let Ok(frame) = build_decadal_frame(chart) {
for period in frame.periods() {
decadal.push((period.palace_branch(), period.start_age(), period.end_age()));
}
}
let mut small: Vec<(EarthlyBranch, Vec<u8>)> = Vec::new();
for base in 1u8..=PALACE_COUNT as u8 {
if let Ok(period) = build_age_period(chart, base) {
let mut ages = Vec::new();
let mut age = base;
while age <= SMALL_LIMIT_MAX_AGE {
ages.push(age);
age += PALACE_COUNT as u8;
}
small.push((period.palace_branch(), ages));
}
}
Self { decadal, small }
}
fn view_for(&self, branch: EarthlyBranch) -> StaticPalaceLimitView {
let ages = self
.small
.iter()
.find(|(palace_branch, _)| *palace_branch == branch)
.map(|(_, ages)| ages.as_slice())
.unwrap_or_default();
StaticPalaceLimitView {
decadal_age_range_zh: self
.decadal
.iter()
.find(|(palace_branch, _, _)| *palace_branch == branch)
.map(|(_, start, end)| format!("{start}-{end}")),
small_limit_ages_zh: ages.iter().map(u8::to_string).collect(),
small_limit_ages: ages.iter().map(|&age| u16::from(age)).collect(),
is_active_decadal: false,
is_active_small_limit: false,
active_small_limit_age: None,
}
}
}
fn build_palaces(chart: &Chart, overlay_layers: &[&TemporalLayer]) -> Vec<StaticPalaceView> {
let limits = PalaceLimits::for_chart(chart);
VISUAL_BRANCH_ORDER
.into_iter()
.filter_map(|branch| {
chart
.palaces()
.iter()
.find(|palace| palace.branch() == branch)
.map(|palace| StaticPalaceView::from_palace(chart, palace, overlay_layers, &limits))
})
.collect()
}
impl StaticPalaceView {
fn from_palace(
chart: &Chart,
palace: &Palace,
overlay_layers: &[&TemporalLayer],
limits: &PalaceLimits,
) -> Self {
let mut roles = vec![StaticPalaceRole::NatalPalace(palace.name())];
if chart.is_body_palace_branch(palace.branch()) {
roles.push(StaticPalaceRole::BodyPalace);
}
let mut major_stars = Vec::new();
let mut minor_stars = Vec::new();
let mut adjective_stars = Vec::new();
let other_typed_stars = Vec::new();
for placement in palace.stars() {
let star = StaticTypedStarView::from_star_placement(placement);
match star.category {
StarCategory::Major => major_stars.push(star),
StarCategory::Minor => minor_stars.push(star),
StarCategory::Adjective => adjective_stars.push(star),
}
}
major_stars.sort_by_key(StaticTypedStarView::order_key);
minor_stars.sort_by_key(StaticTypedStarView::order_key);
adjective_stars.sort_by_key(StaticTypedStarView::order_key);
let mut decorative_stars: Vec<StaticDecorativeStarView> = palace
.decorative_stars()
.iter()
.map(StaticDecorativeStarView::from_decorative_star_placement)
.collect();
decorative_stars.sort_by_key(StaticDecorativeStarView::order_key);
let overlays = overlay_layers
.iter()
.map(|layer| StaticTemporalOverlayView::from_layer(layer, palace.branch()))
.collect();
Self {
branch: palace.branch(),
branch_zh: zh_cn::earthly_branch_zh(palace.branch()).to_owned(),
grid_position: palace_grid_position(palace.branch()),
surround: StaticSurroundPalacesView::for_branch(palace.branch()),
limit: limits.view_for(palace.branch()),
name: palace.name(),
name_zh: zh_cn::palace_name_zh(palace.name()).to_owned(),
stem: palace.stem(),
stem_zh: zh_cn::heavenly_stem_zh(palace.stem()).to_owned(),
roles,
major_stars,
minor_stars,
adjective_stars,
other_typed_stars,
decorative_stars,
overlays,
highlights: Vec::new(),
}
}
}
impl StaticTemporalOverlayView {
fn from_layer(layer: &TemporalLayer, branch: EarthlyBranch) -> Self {
let temporal_palace_name = layer
.palace_layout()
.and_then(|layout| layout.name_for_branch(branch));
let mut typed_stars: Vec<StaticTypedStarView> = layer
.placements()
.iter()
.filter(|placement| placement.branch() == branch)
.map(|placement| StaticTypedStarView::from_star_placement(placement.placement()))
.collect();
typed_stars.sort_by_key(StaticTypedStarView::order_key);
let mut decorative_stars: Vec<StaticDecorativeStarView> = layer
.temporal_decorative_stars()
.iter()
.filter(|placement| placement.branch() == branch)
.map(|placement| {
StaticDecorativeStarView::from_decorative_star_placement(placement.placement())
})
.collect();
decorative_stars.sort_by_key(StaticDecorativeStarView::order_key);
let mutagens = layer
.activations()
.iter()
.filter(|activation| activation.target_branch() == branch)
.map(StaticOverlayMutagenView::from_activation)
.collect();
let is_marker = period_marker_branch(layer) == Some(branch);
let period_stem = layer.context().stem_branch().stem();
let period_label_zh = is_marker.then(|| {
format!(
"{}·{}",
zh_cn::scope_zh(layer.scope()),
zh_cn::heavenly_stem_zh(period_stem)
)
});
Self {
scope: layer.scope(),
period_label_zh,
period_stem: is_marker.then_some(period_stem),
temporal_palace_name,
temporal_palace_name_zh: temporal_palace_name
.map(|name| zh_cn::palace_name_zh(name).to_owned()),
typed_stars,
decorative_stars,
mutagens,
}
}
}
fn period_marker_branch(layer: &TemporalLayer) -> Option<EarthlyBranch> {
layer
.palace_layout()?
.names()
.iter()
.find(|name| name.palace_name() == PalaceName::Life)
.map(TemporalPalaceName::branch)
}
fn present_scopes(chart: &HoroscopeChart) -> Vec<Scope> {
SELECTOR_ORDER
.into_iter()
.filter(|scope| {
*scope == Scope::Natal || chart.layers().iter().any(|layer| layer.scope() == *scope)
})
.collect()
}
fn build_selectors(present: &[Scope], selected: &[Scope]) -> Vec<StaticChartSelectorView> {
SELECTOR_ORDER
.into_iter()
.map(|scope| StaticChartSelectorView {
scope,
label_zh: zh_cn::scope_zh(scope).to_owned(),
enabled: present.contains(&scope),
selected: selected.contains(&scope),
})
.collect()
}
fn active_scopes(selected: &[Scope]) -> Vec<Scope> {
SELECTOR_ORDER
.into_iter()
.filter(|scope| selected.contains(scope))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::model::chart::PALACE_COUNT;
use crate::core::{
ChartAlgorithmKind, LunarChartRequest, LunarDay, LunarMonth, MethodProfile,
SolarChartRequest, SolarDay, SolarMonth, StemBranch, by_lunar, by_solar,
};
use std::collections::HashSet;
fn sample_chart() -> Chart {
let birth_year = StemBranch::from_lunar_year(1990);
let method_profile = MethodProfile::new(
"1990_05_17_chen_female",
ChartAlgorithmKind::QuanShu,
"static chart view unit test",
);
let request = LunarChartRequest::builder()
.lunar_year(1990)
.lunar_month(LunarMonth::new(5).expect("valid lunar month"))
.lunar_day(LunarDay::new(17).expect("valid lunar day"))
.iztro_time_index(4)
.expect("valid time index")
.gender(Gender::Female)
.birth_year_stem(birth_year.stem())
.birth_year_branch(birth_year.branch())
.is_leap_month(false)
.fix_leap(true)
.method_profile(method_profile)
.build()
.expect("lunar chart request should build");
by_lunar(request).expect("by_lunar should build the canonical chart")
}
fn sample_solar_chart() -> Chart {
let method_profile = MethodProfile::new(
"1990_05_17_chen_female_solar",
ChartAlgorithmKind::QuanShu,
"static chart view solar unit test",
);
let request = SolarChartRequest::builder()
.solar_year(1990)
.solar_month(SolarMonth::new(5).expect("valid solar month"))
.solar_day(SolarDay::new(17).expect("valid solar day"))
.iztro_time_index(4)
.expect("valid time index")
.gender(Gender::Female)
.method_profile(method_profile)
.build()
.expect("solar chart request should build");
by_solar(request).expect("by_solar should build the canonical chart")
}
#[test]
fn snapshot_has_exactly_twelve_palaces() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert_eq!(snapshot.palaces.len(), PALACE_COUNT);
}
#[test]
fn every_branch_appears_exactly_once() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
let branches: HashSet<EarthlyBranch> = snapshot
.palaces
.iter()
.map(|palace| palace.branch)
.collect();
assert_eq!(branches.len(), PALACE_COUNT);
assert_eq!(branches, VISUAL_BRANCH_ORDER.into_iter().collect());
}
#[test]
fn every_grid_position_appears_exactly_once_and_matches_layout() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
let positions: HashSet<(u8, u8)> = snapshot
.palaces
.iter()
.map(|palace| (palace.grid_position.row(), palace.grid_position.column()))
.collect();
assert_eq!(positions.len(), PALACE_COUNT);
for palace in &snapshot.palaces {
assert_eq!(palace.grid_position, palace_grid_position(palace.branch));
}
}
#[test]
fn every_palace_has_chinese_labels() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
for palace in &snapshot.palaces {
assert!(!palace.branch_zh.is_empty());
assert!(!palace.name_zh.is_empty());
assert!(!palace.stem_zh.is_empty());
}
}
#[test]
fn solar_chart_center_includes_optional_four_pillar_labels() {
let chart = sample_solar_chart();
let snapshot = StaticChartViewSnapshot::from_chart(&chart);
let center = &snapshot.center;
let pillars = center
.four_pillars
.as_ref()
.expect("by_solar chart should carry four pillars");
assert_eq!(
Some(&pillars.yearly),
chart.four_pillars().map(|p| &p.yearly)
);
assert_eq!(pillars.yearly, chart.birth_year());
assert!(!pillars.yearly_zh.is_empty());
assert!(!pillars.monthly_zh.is_empty());
assert!(!pillars.daily_zh.is_empty());
assert!(!pillars.hourly_zh.is_empty());
}
#[test]
fn every_typed_star_has_chinese_labels_and_mutagen_labels_match() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
for palace in &snapshot.palaces {
let typed = palace
.major_stars
.iter()
.chain(&palace.minor_stars)
.chain(&palace.adjective_stars)
.chain(&palace.other_typed_stars);
for star in typed {
assert!(!star.name_zh.is_empty());
assert!(!star.kind_zh.is_empty());
assert!(!star.category_zh.is_empty());
assert_eq!(star.mutagen.is_some(), star.mutagen_zh.is_some());
}
}
}
#[test]
fn every_decorative_star_has_chinese_labels() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
for palace in &snapshot.palaces {
for star in &palace.decorative_stars {
assert!(!star.name_zh.is_empty());
assert!(!star.family_zh.is_empty());
}
}
}
#[test]
fn typed_stars_are_grouped_by_category_without_loss() {
let chart = sample_chart();
let snapshot = StaticChartViewSnapshot::from_chart(&chart);
for palace in &snapshot.palaces {
for star in &palace.major_stars {
assert_eq!(star.category, StarCategory::Major);
}
for star in &palace.minor_stars {
assert_eq!(star.category, StarCategory::Minor);
}
for star in &palace.adjective_stars {
assert_eq!(star.category, StarCategory::Adjective);
}
let source = chart
.palaces()
.iter()
.find(|p| p.branch() == palace.branch)
.expect("palace by branch");
let grouped = palace.major_stars.len()
+ palace.minor_stars.len()
+ palace.adjective_stars.len()
+ palace.other_typed_stars.len();
assert_eq!(grouped, source.stars().len());
}
assert!(
snapshot
.palaces
.iter()
.all(|p| p.other_typed_stars.is_empty())
);
}
#[test]
fn highlights_are_empty() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert!(snapshot.highlights.is_empty());
assert!(snapshot.palaces.iter().all(|p| p.highlights.is_empty()));
}
#[test]
fn surround_uses_canonical_offsets() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
for palace in &snapshot.palaces {
assert_eq!(palace.surround.opposite, palace.branch.offset(6));
assert_eq!(palace.surround.wealth, palace.branch.offset(8));
assert_eq!(palace.surround.career, palace.branch.offset(4));
assert!(!palace.surround.involves(palace.branch));
assert_eq!(
palace.surround.branches(),
[
palace.surround.opposite,
palace.surround.wealth,
palace.surround.career
]
);
}
}
#[test]
fn surround_roundtrips_through_json() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
let palace = &snapshot.palaces[0];
let encoded = serde_json::to_string(palace).expect("palace serializes");
let decoded: StaticPalaceView =
serde_json::from_str(&encoded).expect("palace deserializes");
assert_eq!(decoded.surround, palace.surround);
assert!(encoded.contains("\"surround\""));
assert!(encoded.contains("\"opposite\""));
assert!(encoded.contains("\"wealth\""));
assert!(encoded.contains("\"career\""));
}
#[test]
fn surround_is_a_natal_fact_independent_of_overlays() {
let chart = sample_horoscope_chart();
let natal_only = StaticChartViewSnapshot::from_chart(chart.natal());
let with_overlays = StaticChartViewSnapshot::from_horoscope_chart(&chart);
for (a, b) in natal_only.palaces.iter().zip(&with_overlays.palaces) {
assert_eq!(a.branch, b.branch);
assert_eq!(a.surround, b.surround);
}
}
#[test]
fn repeated_construction_serializes_identically() {
let chart = sample_chart();
let first = serde_json::to_string(&StaticChartViewSnapshot::from_chart(&chart))
.expect("snapshot should serialize");
let second = serde_json::to_string(&StaticChartViewSnapshot::from_chart(&chart))
.expect("snapshot should serialize");
assert_eq!(first, second);
}
#[test]
fn constructing_the_view_does_not_mutate_natal_facts() {
let chart = sample_chart();
let before = chart.clone();
let _ = StaticChartViewSnapshot::from_chart(&chart);
assert_eq!(chart, before);
}
#[test]
fn from_chart_enables_and_selects_only_natal() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert_eq!(snapshot.active_scopes, vec![Scope::Natal]);
for selector in &snapshot.selectors {
let is_natal = selector.scope == Scope::Natal;
assert_eq!(selector.enabled, is_natal);
assert_eq!(selector.selected, is_natal);
}
let order: Vec<Scope> = snapshot.selectors.iter().map(|s| s.scope).collect();
assert_eq!(order, SELECTOR_ORDER.to_vec());
let natal = &snapshot.selectors[0];
assert_eq!(natal.label_zh, "本命");
}
#[test]
fn from_chart_produces_no_overlays() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert!(snapshot.palaces.iter().all(|p| p.overlays.is_empty()));
}
use crate::core::model::chart::{ScopedStarPlacement, TemporalContext, TemporalLayer};
const OVERLAY_BRANCH: EarthlyBranch = EarthlyBranch::Zi;
fn sample_horoscope_chart() -> HoroscopeChart {
let mut chart = HoroscopeChart::new(sample_chart());
let period = StemBranch::from_lunar_year(2020);
let decadal_star = StarPlacement::new(
StarName::YunLu,
StarKind::LuCun,
Brightness::Unknown,
None,
Scope::Decadal,
);
let decadal_layer = TemporalLayer::try_new(
Scope::Decadal,
TemporalContext::Decadal {
stem_branch: period,
start_age: 6,
},
vec![ScopedStarPlacement::new(OVERLAY_BRANCH, decadal_star)],
vec![MutagenActivation::new(
Scope::Decadal,
StarName::ZiWei,
OVERLAY_BRANCH,
Mutagen::Lu,
)],
)
.expect("decadal layer should build");
chart.push_layer(decadal_layer);
let yearly_layer = TemporalLayer::try_new(
Scope::Yearly,
TemporalContext::Yearly {
stem_branch: period,
lunar_year: 2020,
},
Vec::new(),
Vec::new(),
)
.expect("yearly layer should build");
chart.push_layer(yearly_layer);
chart
}
#[test]
fn from_horoscope_chart_enables_and_selects_present_scopes() {
let chart = sample_horoscope_chart();
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&chart);
let present = [Scope::Natal, Scope::Decadal, Scope::Yearly];
for selector in &snapshot.selectors {
let is_present = present.contains(&selector.scope);
assert_eq!(selector.enabled, is_present, "{:?}", selector.scope);
assert_eq!(selector.selected, is_present, "{:?}", selector.scope);
}
assert_eq!(
snapshot.active_scopes,
vec![Scope::Natal, Scope::Decadal, Scope::Yearly]
);
}
#[test]
fn selector_labels_are_correct() {
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&sample_horoscope_chart());
let labels: Vec<(Scope, &str)> = snapshot
.selectors
.iter()
.map(|s| (s.scope, s.label_zh.as_str()))
.collect();
assert_eq!(
labels,
vec![
(Scope::Natal, "本命"),
(Scope::Decadal, "大限"),
(Scope::Age, "小限"),
(Scope::Yearly, "流年"),
(Scope::Monthly, "流月"),
(Scope::Daily, "流日"),
(Scope::Hourly, "流时"),
]
);
}
#[test]
fn absent_scopes_are_disabled_deterministically() {
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&sample_horoscope_chart());
for scope in [Scope::Age, Scope::Monthly, Scope::Daily, Scope::Hourly] {
let selector = snapshot
.selectors
.iter()
.find(|s| s.scope == scope)
.expect("selector present");
assert!(!selector.enabled);
assert!(!selector.selected);
}
}
#[test]
fn active_scopes_match_requested_visible_scopes() {
let chart = sample_horoscope_chart();
let request = StaticChartViewRequest {
visible_scopes: vec![Scope::Natal, Scope::Decadal, Scope::Monthly],
};
let snapshot = StaticChartViewSnapshot::from_horoscope_chart_with(&chart, &request);
assert_eq!(snapshot.active_scopes, vec![Scope::Natal, Scope::Decadal]);
let yearly = snapshot
.selectors
.iter()
.find(|s| s.scope == Scope::Yearly)
.unwrap();
assert!(yearly.enabled);
assert!(!yearly.selected);
}
#[test]
fn natal_is_always_active_for_horoscope_static_views() {
let chart = sample_horoscope_chart();
let request = StaticChartViewRequest {
visible_scopes: vec![Scope::Yearly],
};
let snapshot = StaticChartViewSnapshot::from_horoscope_chart_with(&chart, &request);
assert_eq!(snapshot.active_scopes, vec![Scope::Natal, Scope::Yearly]);
let natal = snapshot
.selectors
.iter()
.find(|selector| selector.scope == Scope::Natal)
.expect("natal selector");
assert!(natal.enabled);
assert!(natal.selected);
}
#[test]
fn selected_overlays_appear_only_on_the_overlay_branch() {
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&sample_horoscope_chart());
for palace in &snapshot.palaces {
assert_eq!(palace.overlays.len(), 2, "{:?}", palace.branch);
let decadal = palace
.overlays
.iter()
.find(|o| o.scope == Scope::Decadal)
.expect("decadal overlay");
if palace.branch == OVERLAY_BRANCH {
assert_eq!(decadal.typed_stars.len(), 1);
assert_eq!(decadal.typed_stars[0].name, StarName::YunLu);
assert_eq!(decadal.mutagens.len(), 1);
assert_eq!(decadal.mutagens[0].mutagen, Mutagen::Lu);
} else {
assert!(decadal.typed_stars.is_empty());
assert!(decadal.mutagens.is_empty());
}
}
}
#[test]
fn selecting_scopes_changes_only_overlays_not_natal_facts() {
let chart = sample_horoscope_chart();
let natal_only = StaticChartViewSnapshot::from_chart(chart.natal());
let with_overlays = StaticChartViewSnapshot::from_horoscope_chart(&chart);
assert_eq!(natal_only.center, with_overlays.center);
for (a, b) in natal_only.palaces.iter().zip(&with_overlays.palaces) {
assert_eq!(a.branch, b.branch);
assert_eq!(a.major_stars, b.major_stars);
assert_eq!(a.minor_stars, b.minor_stars);
assert_eq!(a.adjective_stars, b.adjective_stars);
assert_eq!(a.decorative_stars, b.decorative_stars);
assert_eq!(a.roles, b.roles);
assert!(a.overlays.is_empty());
assert!(!b.overlays.is_empty());
}
}
#[test]
fn horoscope_snapshot_serializes_deterministically_and_keeps_natal_immutable() {
let chart = sample_horoscope_chart();
let before = chart.clone();
let first = serde_json::to_string(&StaticChartViewSnapshot::from_horoscope_chart(&chart))
.expect("serialize");
let second = serde_json::to_string(&StaticChartViewSnapshot::from_horoscope_chart(&chart))
.expect("serialize");
assert_eq!(first, second);
assert_eq!(chart, before);
}
}