use crate::land::{
centroid, load_map, place_kind, place_score, plot_containing, rating_tier, PlaceKind, FLAG_SVG,
};
use crate::nav::CyberiaNav;
use leptos::prelude::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SortKey {
ScoreDesc,
Name,
Kind,
Plot,
}
#[derive(Clone, Debug)]
struct PlaceRow {
id: String,
name: String,
kind: PlaceKind,
score: f64,
plot_id: Option<String>,
plot_name: Option<String>,
plot_zone: Option<String>,
lon: f64,
lat: f64,
}
#[component]
pub fn PlacesPage() -> impl IntoView {
let map = load_map();
let rows: Vec<PlaceRow> = map
.places
.iter()
.enumerate()
.map(|(i, p)| {
let (lon, lat) = centroid(&p.coords).unwrap_or((0.0, 0.0));
let kind = place_kind(&p.name, &p.id);
let score = place_score(&p.name, &p.id, kind);
let host = plot_containing(&map.phase0, lon, lat);
let id = if p.id.is_empty() {
format!("place-{i}")
} else {
format!("{}-{}", p.id, i)
};
PlaceRow {
id,
name: p.name.clone(),
kind,
score,
plot_id: host.map(|h| h.id.clone()),
plot_name: host.map(|h| h.name.clone()),
plot_zone: host.map(|h| {
if h.zone.is_empty() {
h.id.split('-').next().unwrap_or("โ").to_string()
} else {
h.zone.clone()
}
}),
lon,
lat,
}
})
.collect();
let rows_sig = RwSignal::new(rows);
let sort = RwSignal::new(SortKey::ScoreDesc);
let kind_filter = RwSignal::new(None::<PlaceKind>);
let q = RwSignal::new(String::new());
Effect::new(move |_| {
document().set_title("Cyberia โ places");
});
let filtered = move || {
let kf = kind_filter.get();
let query = q.get().trim().to_lowercase();
let mut list: Vec<PlaceRow> = rows_sig
.get()
.into_iter()
.filter(|r| kf.map(|k| r.kind == k).unwrap_or(true))
.filter(|r| {
if query.is_empty() {
true
} else {
r.name.to_lowercase().contains(&query)
|| r.id.to_lowercase().contains(&query)
|| r.plot_name
.as_ref()
.map(|n| n.to_lowercase().contains(&query))
.unwrap_or(false)
|| r.plot_zone
.as_ref()
.map(|n| n.to_lowercase().contains(&query))
.unwrap_or(false)
}
})
.collect();
match sort.get() {
SortKey::ScoreDesc => list.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
}),
SortKey::Name => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
SortKey::Kind => list.sort_by(|a, b| {
a.kind
.label()
.cmp(b.kind.label())
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
}),
SortKey::Plot => list.sort_by(|a, b| {
a.plot_name
.as_deref()
.unwrap_or("~")
.cmp(b.plot_name.as_deref().unwrap_or("~"))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
}),
}
list
};
let n_show = move || filtered().len();
let n_estate = move || {
filtered()
.iter()
.filter(|r| r.kind == PlaceKind::Estate)
.count()
};
view! {
<div class="page-shell cities-shell">
<div class="site-chrome cyberia-chrome">
<div class="chrome-inner">
<div class="header-row1">
<div class="logo-zone">
<h1 class="logo">
<a href="/cities" class="brand-flag" title="home" inner_html=FLAG_SVG></a>
<span style="color: var(--cyber-green);">"cyber"</span>
<span style="color: var(--cyber-green); margin: 0 1px;">"โข"</span>
<span style="color: #fff;">"ia"</span>
</h1>
</div>
<div class="cyberia-phase-pill">
<span class="phase-dot"></span>
{move || format!("{} PLACES", n_show())}
</div>
<CyberiaNav active="places" />
</div>
</div>
</div>
<div class="cities-stage">
<div class="cities-hero">
<div>
<div class="cities-kicker">"ON LAND ยท CYBER VALLEY"</div>
<h2 class="cities-title">"Places"</h2>
<p class="cities-lead">
"Estates, buildings, and points on plots โ houses, amenities, trails, nature."
</p>
</div>
</div>
<div class="list-toolbar">
<input
class="found-input list-search"
type="search"
placeholder="search place ยท plot ยท zone"
prop:value=move || q.get()
on:input=move |ev| q.set(event_target_value(&ev))
/>
<div class="list-filters">
<button
class=move || if kind_filter.get().is_none() { "chip chip-on" } else { "chip" }
on:click=move |_| kind_filter.set(None)
>"ALL"</button>
<button
class=move || if kind_filter.get() == Some(PlaceKind::Estate) { "chip chip-on" } else { "chip" }
on:click=move |_| kind_filter.set(Some(PlaceKind::Estate))
>"ESTATE"</button>
<button
class=move || if kind_filter.get() == Some(PlaceKind::Landmark) { "chip chip-on" } else { "chip" }
on:click=move |_| kind_filter.set(Some(PlaceKind::Landmark))
>"LANDMARK"</button>
<button
class=move || if kind_filter.get() == Some(PlaceKind::Amenity) { "chip chip-on" } else { "chip" }
on:click=move |_| kind_filter.set(Some(PlaceKind::Amenity))
>"AMENITY"</button>
<button
class=move || if kind_filter.get() == Some(PlaceKind::Nature) { "chip chip-on" } else { "chip" }
on:click=move |_| kind_filter.set(Some(PlaceKind::Nature))
>"NATURE"</button>
<button
class=move || if kind_filter.get() == Some(PlaceKind::Trail) { "chip chip-on" } else { "chip" }
on:click=move |_| kind_filter.set(Some(PlaceKind::Trail))
>"TRAIL"</button>
</div>
<div class="list-sorts">
<span class="list-sort-label">"SORT"</span>
<button
class=move || if sort.get() == SortKey::ScoreDesc { "chip chip-on" } else { "chip" }
on:click=move |_| sort.set(SortKey::ScoreDesc)
>"RATING โ"</button>
<button
class=move || if sort.get() == SortKey::Name { "chip chip-on" } else { "chip" }
on:click=move |_| sort.set(SortKey::Name)
>"NAME"</button>
<button
class=move || if sort.get() == SortKey::Kind { "chip chip-on" } else { "chip" }
on:click=move |_| sort.set(SortKey::Kind)
>"KIND"</button>
<button
class=move || if sort.get() == SortKey::Plot { "chip chip-on" } else { "chip" }
on:click=move |_| sort.set(SortKey::Plot)
>"PLOT"</button>
</div>
</div>
<div class="cities-grid places-grid">
{move || filtered().into_iter().enumerate().map(|(i, r)| {
let rank = i + 1;
let tier = rating_tier(r.score);
let score = r.score;
let name = r.name.to_uppercase();
let kind_l = r.kind.label();
let plot_line = match (&r.plot_name, &r.plot_zone) {
(Some(pn), Some(pz)) => format!("{} ยท {}", pn.to_uppercase(), pz.to_uppercase()),
(Some(pn), None) => pn.to_uppercase(),
_ => "off-plot / open land".into(),
};
let href = if let Some(pid) = &r.plot_id {
format!("/map?plot={pid}")
} else {
format!("/map?lat={:.6}&lon={:.6}", r.lat, r.lon)
};
let is_estate = r.kind == PlaceKind::Estate;
let kind_cls = match r.kind {
PlaceKind::Estate => "place-kind estate",
PlaceKind::Amenity => "place-kind amenity",
PlaceKind::Nature => "place-kind nature",
PlaceKind::Trail => "place-kind trail",
PlaceKind::Landmark => "place-kind landmark",
};
view! {
<a class=if is_estate { "city-card live place-card" } else { "city-card place-card" } href=href>
<div class="city-card-top">
<span class="city-rank">{format!("#{rank:02}")}</span>
<span class=format!("rating-badge tier-{}", tier.to_lowercase())>
{format!("{tier} ยท {score:.0}")}
</span>
</div>
<div class="city-name">{name}</div>
<div class="city-region">{plot_line}</div>
<p class="city-blurb">
{match r.kind {
PlaceKind::Estate => "Named hold / building on a plot.",
PlaceKind::Amenity => "Site amenity โ parking, WC, pad.",
PlaceKind::Nature => "Natural feature on or near land.",
PlaceKind::Trail => "Path, bridge, or trail node.",
PlaceKind::Landmark => "Land point / marker.",
}}
</p>
<div class="city-meta">
<span class=kind_cls>{kind_l}</span>
<span class="city-open">"MAP โ"</span>
</div>
</a>
}
}).collect_view()}
</div>
</div>
<div class="search-dock cyberia-dock cities-dock">
<span class="dock-count">
{move || format!(
"{} places ยท {} estates ยท soft labels",
n_show(),
n_estate()
)}
</span>
<a class="cta-btn cta-lease cta-lg dock-found" href="/map" style="text-decoration:none; max-width: 280px;">
<span class="cta-copy">
<span class="cta-title">"MAP"</span>
<span class="cta-sub">"see on land"</span>
</span>
</a>
<a href="https://x.com/cyberiacap" target="_blank" rel="noopener" class="dock-credit">
"๐ด a "<span style="color: var(--cyber-green);">"cyberia"</span>" project"
</a>
</div>
</div>
}
}