//! Experimental cyberia console: fleets & flats.
//!
//! soft3 compliance (experimental surface):
//! - intent is first-class (assign fleet ร action ร flat)
//! - open map data (public Gesing KML), no closed gatekeeper
//! - client-local queue only โ no proprietary backend API
//! - vocabulary: fleet (work), flat (hold/space), intent (write)
use crate::land::FLAG_SVG;
use crate::nav::CyberiaNav;
use crate::robots::{load_owned, save_owned, OwnedRobot};
use crate::wallet::{
debit_cx, load_intents, load_leases, save_intents, save_leases, IntentRec, Lease,
};
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::JsCast;
const MAP_JSON: &str = include_str!("cyberia_map.json");
#[derive(Clone, Debug, Deserialize)]
struct MapData {
site: String,
center: [f64; 2],
bbox: BBox,
#[serde(default)]
stats: MapStats,
/// All interactive land plots (from KML `plots` folder).
phase0: Vec<Flat>,
#[serde(default)]
districts: Vec<Flat>,
places: Vec<Flat>,
/// 21 cybics domains as citadel shill points (no core/bridge).
#[serde(default)]
domains: Vec<DomainMark>,
source: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
struct DomainMark {
id: String,
name: String,
#[serde(default)]
triad: String,
#[serde(default)]
question: String,
#[serde(default)]
zone: String,
#[serde(default)]
district: String,
#[serde(default)]
shill: String,
#[serde(default)]
href: String,
coords: Vec<[f64; 2]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LeftBoard {
Fleets,
Domains,
}
const DOMAIN_POS_KEY: &str = "cyberia.domain_positions.v1";
fn triad_color(triad: &str) -> &'static str {
match triad {
"form" => "#00b4ff", // cyan โ rules
"mass" => "#ff6501", // orange โ matter
"space" => "#1700fe", // blue โ where
"life" => "#00ff01", // green โ alive
"word" => "#6b00fe", // violet โ meaning
"work" => "#ffc501", // yellow โ making
"play" => "#fe0000", // red โ coordinate
_ => "#00ff01",
}
}
/// Canonical cybics triad order for domain board grouping.
const TRIAD_ORDER: &[&str] = &["form", "mass", "space", "life", "word", "work", "play"];
fn triad_question(triad: &str) -> &'static str {
match triad {
"form" => "what are the rules?",
"mass" => "what is it made of?",
"space" => "where does it happen?",
"life" => "who is alive?",
"word" => "what does it mean?",
"work" => "how is it made?",
"play" => "how do we coordinate?",
_ => "",
}
}
#[derive(Clone, Debug, Default, Deserialize)]
struct MapStats {
#[serde(default)]
plot_count: u32,
#[serde(default)]
plot_ha: f64,
#[serde(default)]
district_ha: f64,
}
#[derive(Clone, Debug, Deserialize)]
struct BBox {
min_lon: f64,
max_lon: f64,
min_lat: f64,
max_lat: f64,
}
fn load_domain_positions() -> HashMap<String, [f64; 2]> {
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|ls| ls.get_item(DOMAIN_POS_KEY).ok().flatten())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_domain_positions(domains: &[DomainMark]) {
let mut m = HashMap::new();
for d in domains {
if let Some(c) = d.coords.first() {
m.insert(d.id.clone(), [c[0], c[1]]);
m.insert(d.name.clone(), [c[0], c[1]]);
}
}
if let Some(ls) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
if let Ok(s) = serde_json::to_string(&m) {
let _ = ls.set_item(DOMAIN_POS_KEY, &s);
}
}
}
fn clear_domain_positions() {
if let Some(ls) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
let _ = ls.remove_item(DOMAIN_POS_KEY);
}
}
fn apply_domain_positions(mut domains: Vec<DomainMark>) -> Vec<DomainMark> {
let pos = load_domain_positions();
for d in &mut domains {
if let Some(c) = pos.get(&d.id).or_else(|| pos.get(&d.name)) {
d.coords = vec![[c[0], c[1]]];
}
}
domains
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
struct Flat {
id: String,
name: String,
kind: String,
phase: u32,
geom: String,
coords: Vec<[f64; 2]>,
#[serde(default)]
zone: String,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct FleetUnit {
id: &'static str,
name: &'static str,
kind: &'static str, // worker | machine
role: &'static str,
status: &'static str, // idle | busy | offline
phase: u32,
}
/// Existing Gesing hard-force workers (ops roster) โ 15 people on site.
/// Source: cve/ops/hard force.md crews (repair ยท cube ยท base ยท pruning ยท delivery).
const WORKERS: &[FleetUnit] = &[
// repair
FleetUnit {
id: "w-sutar",
name: "SUTAR",
kind: "worker",
role: "repair lead ยท energy/water",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-witaya",
name: "WITAYA",
kind: "worker",
role: "repair ยท electronics",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-lupus",
name: "LUPUS",
kind: "worker",
role: "repair ยท mechanical",
status: "idle",
phase: 0,
},
// cube
FleetUnit {
id: "w-sudi",
name: "SUDI",
kind: "worker",
role: "cube lead ยท build",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-budi",
name: "BUDI",
kind: "worker",
role: "cube ยท build",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-tika",
name: "TIKA",
kind: "worker",
role: "cube ยท stove",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-sastra",
name: "SASTRA",
kind: "worker",
role: "cube ยท build",
status: "idle",
phase: 0,
},
// base
FleetUnit {
id: "w-angga",
name: "ANGGA",
kind: "worker",
role: "base lead ยท road/trail",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-darma",
name: "DARMA",
kind: "worker",
role: "base ยท mason",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-darsana",
name: "DARSANA",
kind: "worker",
role: "base ยท terrace",
status: "idle",
phase: 0,
},
// pruning
FleetUnit {
id: "w-arima",
name: "ARIMA",
kind: "worker",
role: "pruning lead ยท land",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-doplang",
name: "DOPLANG",
kind: "worker",
role: "pruning ยท firewood",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-surya",
name: "SURYA",
kind: "worker",
role: "pruning ยท fodder",
status: "idle",
phase: 0,
},
FleetUnit {
id: "w-suardita",
name: "SUARDITA",
kind: "worker",
role: "pruning ยท compost",
status: "idle",
phase: 0,
},
// delivery
FleetUnit {
id: "w-pande",
name: "PANDE",
kind: "worker",
role: "delivery lead ยท haul",
status: "idle",
phase: 0,
},
];
/// Phase-0 machines (hardware fleet).
const MACHINES: &[FleetUnit] = &[
FleetUnit {
id: "f-eye",
name: "EYE-01",
kind: "machine",
role: "survey drone",
status: "idle",
phase: 0,
},
FleetUnit {
id: "f-haul",
name: "HAUL-01",
kind: "machine",
role: "ground rover",
status: "idle",
phase: 0,
},
FleetUnit {
id: "f-cut",
name: "CUT-01",
kind: "machine",
role: "clearing arm",
status: "offline",
phase: 0,
},
FleetUnit {
id: "f-build",
name: "CUBE-01",
kind: "machine",
role: "build stack",
status: "offline",
phase: 1,
},
];
fn all_stock_fleets() -> impl Iterator<Item = &'static FleetUnit> {
WORKERS.iter().chain(MACHINES.iter())
}
fn stock_fleet(id: &str) -> Option<&'static FleetUnit> {
all_stock_fleets().find(|f| f.id == id)
}
/// Open ring (no duplicate close point).
fn open_ring(coords: &span>; 2) -> Vec<[f64; 2]> {
let mut c = coords.to_vec();
if c.len() > 1 && c.first() == c.last() {
c.pop();
}
c
}
fn close_ring(mut c: Vec<[f64; 2]>) -> Vec<[f64; 2]> {
if c.len() >= 2 && c.first() != c.last() {
let first = c[0];
c.push(first);
}
c
}
fn poly_bbox(coords: &span>; 2) -> (f64, f64, f64, f64) {
let ring = open_ring(coords);
let mut min_lon = f64::INFINITY;
let mut max_lon = f64::NEG_INFINITY;
let mut min_lat = f64::INFINITY;
let mut max_lat = f64::NEG_INFINITY;
for p in &ring {
min_lon = min_lon.min(p[0]);
max_lon = max_lon.max(p[0]);
min_lat = min_lat.min(p[1]);
max_lat = max_lat.max(p[1]);
}
(min_lon, max_lon, min_lat, max_lat)
}
/// Planar area in mยฒ via equirectangular projection around centroid (WGS84).
fn area_m2(coords: &span>; 2) -> f64 {
let ring = open_ring(coords);
if ring.len() < 3 {
return 0.0;
}
let lat0 = ring.iter().map(|c| c[1]).sum::<f64>() / ring.len() as f64;
let lon0 = ring.iter().map(|c| c[0]).sum::<f64>() / ring.len() as f64;
const R: f64 = 6_378_137.0;
let cos_lat = lat0.to_radians().cos();
let to_xy = |lon: f64, lat: f64| -> (f64, f64) {
let x = (lon - lon0).to_radians() * R * cos_lat;
let y = (lat - lat0).to_radians() * R;
(x, y)
};
let mut a = 0.0;
for i in 0..ring.len() {
let (x1, y1) = to_xy(ring[i][0], ring[i][1]);
let j = (i + 1) % ring.len();
let (x2, y2) = to_xy(ring[j][0], ring[j][1]);
a += x1 * y2 - x2 * y1;
}
(a.abs()) * 0.5
}
fn fmt_area_m2(m2: f64) -> String {
if m2 >= 10_000.0 {
format!("{:.2} ha ({:.0} mยฒ)", m2 / 10_000.0, m2)
} else if m2 >= 100.0 {
format!("{:.0} mยฒ", m2)
} else {
format!("{:.1} mยฒ", m2)
}
}
/// Bbox span along axis in meters (axis 0 = lon width, 1 = lat height).
fn bbox_span_m(coords: &span>; 2, axis: usize) -> f64 {
let (min_lon, max_lon, min_lat, max_lat) = poly_bbox(coords);
const R: f64 = 6_378_137.0;
let mid_lat = (min_lat + max_lat) * 0.5;
if axis == 0 {
(max_lon - min_lon).to_radians() * R * mid_lat.to_radians().cos()
} else {
(max_lat - min_lat).to_radians() * R
}
}
/// SutherlandโHodgman clip against half-plane on lon (axis=0) or lat (axis=1).
/// `keep_le`: keep points with value โค t (west / south side).
fn clip_half_plane(poly: &span>; 2, axis: usize, t: f64, keep_le: bool) -> Vec<[f64; 2]> {
let ring = open_ring(poly);
if ring.is_empty() {
return vec![];
}
let inside = |p: [f64; 2]| -> bool {
let v = p[axis];
if keep_le {
v <= t + 1e-12
} else {
v >= t - 1e-12
}
};
let intersect = |a: [f64; 2], b: [f64; 2]| -> [f64; 2] {
let da = a[axis] - t;
let db = b[axis] - t;
let denom = da - db;
let u = if denom.abs() < 1e-15 { 0.5 } else { da / denom };
[a[0] + u * (b[0] - a[0]), a[1] + u * (b[1] - a[1])]
};
let mut out: Vec<[f64; 2]> = Vec::new();
let n = ring.len();
for i in 0..n {
let cur = ring[i];
let prev = ring[(i + n - 1) % n];
let cur_in = inside(cur);
let prev_in = inside(prev);
if cur_in {
if !prev_in {
out.push(intersect(prev, cur));
}
out.push(cur);
} else if prev_in {
out.push(intersect(prev, cur));
}
}
// dedupe consecutive near-equal points
let mut cleaned: Vec<[f64; 2]> = Vec::new();
for p in out {
if cleaned
.last()
.map(|q| (q[0] - p[0]).abs() > 1e-10 || (q[1] - p[1]).abs() > 1e-10)
.unwrap_or(true)
{
cleaned.push(p);
}
}
close_ring(cleaned)
}
/// User-controlled split: axis 0 = vertical cut (E/W), 1 = horizontal (N/S).
/// `ratio` in (0,1) is cut position across the polygon bbox (0 = west/south).
fn split_flat_at(flat: &Flat, axis: usize, ratio: f64) -> Option<(Flat, Flat, f64)> {
let r = ratio.clamp(0.05, 0.95);
let (min_lon, max_lon, min_lat, max_lat) = poly_bbox(&flat.coords);
let t = if axis == 0 {
min_lon + r * (max_lon - min_lon)
} else {
min_lat + r * (max_lat - min_lat)
};
let a_coords = clip_half_plane(&flat.coords, axis, t, true);
let b_coords = clip_half_plane(&flat.coords, axis, t, false);
// closed ring needs โฅ4 points (triangle + close)
if a_coords.len() < 4 || b_coords.len() < 4 {
return None;
}
if area_m2(&a_coords) < 1.0 || area_m2(&b_coords) < 1.0 {
return None;
}
let a = Flat {
id: format!("{}-a", flat.id),
name: format!("{}-a", flat.name),
kind: flat.kind.clone(),
phase: flat.phase,
geom: "polygon".into(),
coords: a_coords,
zone: flat.zone.clone(),
};
let b = Flat {
id: format!("{}-b", flat.id),
name: format!("{}-b", flat.name),
kind: flat.kind.clone(),
phase: flat.phase,
geom: "polygon".into(),
coords: b_coords,
zone: flat.zone.clone(),
};
Some((a, b, t))
}
fn merge_flats(a: &Flat, b: &Flat) -> Flat {
let mut coords = a.coords.clone();
if coords.len() > 1 && coords.first() == coords.last() {
coords.pop();
}
for c in &b.coords {
if coords.last() != Some(c) {
coords.push(*c);
}
}
if coords.len() >= 2 && coords.first() != coords.last() {
coords.push(coords[0]);
}
Flat {
id: format!("{}+{}", a.id, b.id),
name: format!("{}+{}", a.name, b.name),
kind: a.kind.clone(),
phase: a.phase.min(b.phase),
geom: "polygon".into(),
coords,
zone: if a.zone == b.zone {
a.zone.clone()
} else {
"mixed".into()
},
}
}
fn zone_key(flat: &Flat) -> &str {
if !flat.zone.is_empty() {
return flat.zone.as_str();
}
// fallback: prefix of id/name
let s = if flat.id.is_empty() {
flat.name.as_str()
} else {
flat.id.as_str()
};
s
}
fn flat_fill(zone_or_id: &str, selected: bool) -> &'static str {
let z = zone_or_id.to_lowercase();
// Soft glass tiles โ readable color, not neon slabs (edges carry definition)
let (hi, lo) = if z.contains("avalon") {
("rgba(255,120,20,0.48)", "rgba(255,102,0,0.22)")
} else if z.contains("sinwood") {
("rgba(20,255,80,0.46)", "rgba(0,230,50,0.20)")
} else if z.contains("bridge") {
("rgba(40,235,255,0.44)", "rgba(0,210,240,0.18)")
} else if z.contains("core") {
("rgba(255,220,40,0.46)", "rgba(255,210,0,0.20)")
} else if z.contains("ether") {
("rgba(180,100,255,0.46)", "rgba(150,70,255,0.20)")
} else if z.contains("asgard") {
("rgba(255,50,90,0.44)", "rgba(255,20,70,0.18)")
} else if z.contains("edem") || z.contains("canyon") {
("rgba(0,255,210,0.44)", "rgba(0,240,190,0.18)")
} else if z.contains("front") || z.contains("avatar") {
("rgba(200,200,210,0.40)", "rgba(160,160,170,0.16)")
} else {
("rgba(180,180,180,0.40)", "rgba(140,140,140,0.16)")
};
if selected {
hi
} else {
lo
}
}
/// Idle borders stay dim so hover/select glow actually reads.
/// Hot = hover or selected โ zone neon / white.
fn flat_stroke_col(zone_or_id: &str, selected: bool, hover: bool) -> &'static str {
let z = zone_or_id.to_lowercase();
if selected {
return "#ffffff";
}
if hover {
// bright edge โ the only "glowing" state
return if z.contains("avalon") {
"#ffb070"
} else if z.contains("sinwood") {
"#7dff9a"
} else if z.contains("bridge") {
"#9af6ff"
} else if z.contains("core") {
"#ffe98a"
} else if z.contains("ether") {
"#e0a8ff"
} else if z.contains("asgard") {
"#ff8aa0"
} else if z.contains("edem") || z.contains("canyon") {
"#8affe8"
} else {
"#e8e8e8"
};
}
// idle: deep, low-contrast seams (tile grid, not neon wire)
if z.contains("avalon") {
"rgba(255,120,40,0.18)"
} else if z.contains("sinwood") {
"rgba(40,220,80,0.16)"
} else if z.contains("bridge") {
"rgba(40,210,230,0.16)"
} else if z.contains("core") {
"rgba(230,200,40,0.16)"
} else if z.contains("ether") {
"rgba(160,90,255,0.16)"
} else if z.contains("asgard") {
"rgba(255,50,90,0.16)"
} else if z.contains("edem") || z.contains("canyon") {
"rgba(0,230,190,0.16)"
} else {
"rgba(180,180,180,0.14)"
}
}
fn zone_hover_fill(zone_or_id: &str) -> &'static str {
// fill lift on hover โ still secondary to the glowing stroke
let z = zone_or_id.to_lowercase();
if z.contains("avalon") {
"rgba(255,130,30,0.42)"
} else if z.contains("sinwood") {
"rgba(40,255,90,0.40)"
} else if z.contains("bridge") {
"rgba(50,240,255,0.38)"
} else if z.contains("core") {
"rgba(255,225,50,0.40)"
} else if z.contains("ether") {
"rgba(170,90,255,0.40)"
} else if z.contains("asgard") {
"rgba(255,40,80,0.38)"
} else if z.contains("edem") || z.contains("canyon") {
"rgba(20,255,200,0.38)"
} else {
"rgba(200,200,200,0.32)"
}
}
fn render_fleet_card(
f: &'static FleetUnit,
selected_fleet: RwSignal<Option<String>>,
) -> impl IntoView {
let id = f.id.to_string();
let id2 = id.clone();
let offline = f.status == "offline" || f.phase > 0;
let kind_worker = f.kind == "worker";
let name = f.name;
let role = f.role;
let kind = f.kind;
let status = f.status;
let phase = f.phase;
view! {
<button
class=move || {
let sel = selected_fleet.get().as_deref() == Some(id.as_str());
format!(
"fleet-card{}{}{}",
if sel { " sel" } else { "" },
if offline { " offline" } else { "" },
if kind_worker { " worker" } else { " machine" },
)
}
disabled=offline
on:click=move |_| {
if !offline {
selected_fleet.set(Some(id2.clone()));
}
}
>
<div class="fleet-top">
<span class="fleet-name">{name}</span>
<span class="fleet-status" style:color=status_color(status)>
{status.to_uppercase()}
</span>
</div>
<div class="fleet-role">{role}</div>
<div class="fleet-meta">
<span>{kind.to_uppercase()}</span>
<span>{format!("P{phase}")}</span>
</div>
</button>
}
}
const ACTIONS: &[(&str, &str)] = &[
("survey", "map + sense the flat"),
("clear", "remove undergrowth / debris"),
("plant", "set regeneratives"),
("haul", "move material on-site"),
("watch", "hold a perimeter watch"),
("build", "raise a cyberCube (phase 1+)"),
];
/// Robots available for purchase (catalog, not yet owned).
const ROBOT_CATALOG: &[FleetUnit] = &[
FleetUnit {
id: "cat-eye",
name: "EYE",
kind: "machine",
role: "survey drone",
status: "idle",
phase: 0,
},
FleetUnit {
id: "cat-hand",
name: "HAND",
kind: "worker",
role: "field hand",
status: "idle",
phase: 0,
},
FleetUnit {
id: "cat-haul",
name: "HAUL",
kind: "machine",
role: "ground rover",
status: "idle",
phase: 0,
},
FleetUnit {
id: "cat-cut",
name: "CUT",
kind: "machine",
role: "clearing arm",
status: "idle",
phase: 0,
},
];
#[derive(Clone, Copy, Debug, PartialEq)]
enum Sheet {
None,
BuyRobot,
LeaseLand,
SplitLand,
MergeLand,
}
fn load_map() -> MapData {
serde_json::from_str(MAP_JSON).expect("cyberia_map.json")
}
/// Project lon/lat into a viewBox-local SVG plane (y flipped).
fn project(lon: f64, lat: f64, bbox: &BBox, w: f64, h: f64, pad: f64) -> (f64, f64) {
let dx = (bbox.max_lon - bbox.min_lon).max(1e-9);
let dy = (bbox.max_lat - bbox.min_lat).max(1e-9);
// square-ish fit with padding
let x = pad + (lon - bbox.min_lon) / dx * (w - 2.0 * pad);
let y = pad + (1.0 - (lat - bbox.min_lat) / dy) * (h - 2.0 * pad);
(x, y)
}
/// Inverse of `project` โ SVG/world โ lon/lat (for domain drag).
fn unproject(x: f64, y: f64, bbox: &BBox, w: f64, h: f64, pad: f64) -> (f64, f64) {
let dx = (bbox.max_lon - bbox.min_lon).max(1e-9);
let dy = (bbox.max_lat - bbox.min_lat).max(1e-9);
let uw = (w - 2.0 * pad).max(1e-9);
let uh = (h - 2.0 * pad).max(1e-9);
let lon = bbox.min_lon + (x - pad) / uw * dx;
let lat = bbox.min_lat + (1.0 - (y - pad) / uh) * dy;
(lon, lat)
}
fn poly_path(coords: &span>; 2, bbox: &BBox, w: f64, h: f64, pad: f64) -> String {
if coords.is_empty() {
return String::new();
}
let mut s = String::new();
for (i, c) in coords.iter().enumerate() {
let (x, y) = project(c[0], c[1], bbox, w, h, pad);
if i == 0 {
s.push_str(&format!("M{x:.2},{y:.2}"));
} else {
s.push_str(&format!(" L{x:.2},{y:.2}"));
}
}
s.push('Z');
s
}
/// Game camera zoom range.
/// Max ~1000 โ ~1.5 m view width on Gesing site โ 10 cm tile โ 60โ70 screen px
/// (enough to layout tiny hex/tiles as a planning game).
const MAP_ZOOM_MIN: f64 = 0.5;
const MAP_ZOOM_MAX: f64 = 1000.0;
/// Keep HUD-sized marks (labels, dots) constant on screen while viewBox zooms.
/// Anchor at (x,y) in SVG/world units.
fn screen_stable_tf(x: f64, y: f64, zoom: f64) -> String {
let s = 1.0 / zoom.max(0.25);
format!("translate({x:.2},{y:.2}) scale({s:.4})")
}
/// SVG camera: viewBox centered at `center` with size (W/z)ร(H/z).
/// Pure vector zoom โ no CSS scale raster blur.
fn map_view_box(center: (f64, f64), zoom: f64, w: f64, h: f64) -> String {
let z = zoom.max(0.25);
let vw = w / z;
let vh = h / z;
format!(
"{:.4} {:.4} {:.4} {:.4}",
center.0 - vw * 0.5,
center.1 - vh * 0.5,
vw,
vh
)
}
/// Site envelope size in meters from lon/lat bbox (approx WGS84).
fn site_span_m(bbox: &BBox) -> (f64, f64) {
let lat0 = (bbox.min_lat + bbox.max_lat) * 0.5;
let m_lat = 111_320.0;
let m_lon = 111_320.0 * lat0.to_radians().cos();
let ew = (bbox.max_lon - bbox.min_lon) * m_lon;
let ns = (bbox.max_lat - bbox.min_lat) * m_lat;
(ew, ns)
}
/// Visible world width (meters) at current zoom โ for HUD scale readout.
fn view_width_m(zoom: f64, map_w: f64, pad: f64, site_ew_m: f64) -> f64 {
let z = zoom.max(0.25);
let usable = (map_w - 2.0 * pad).max(1.0);
let m_per_svg = site_ew_m / usable;
(map_w / z) * m_per_svg
}
fn format_zoom_hud(zoom: f64, view_m: f64) -> String {
let z = zoom;
let z_s = if z >= 100.0 {
format!("ร{z:.0}")
} else if z >= 10.0 {
format!("ร{z:.1}")
} else {
format!("ร{z:.2}")
};
let scale = if view_m >= 100.0 {
format!("~{view_m:.0} m")
} else if view_m >= 10.0 {
format!("~{view_m:.1} m")
} else if view_m >= 1.0 {
format!("~{view_m:.2} m")
} else {
format!("~{:.0} cm", view_m * 100.0)
};
format!("{z_s} ยท {scale}")
}
/// Map client point (relative to wrap) โ world/SVG coords under current camera.
fn client_to_world(
cx: f64,
cy: f64,
wrap_w: f64,
wrap_h: f64,
center: (f64, f64),
zoom: f64,
w: f64,
h: f64,
) -> (f64, f64) {
let z = zoom.max(0.25);
let vw = w / z;
let vh = h / z;
let left = center.0 - vw * 0.5;
let top = center.1 - vh * 0.5;
// meet: uniform scale of viewBox into wrap
let fit = (wrap_w / vw).min(wrap_h / vh);
let ox = (wrap_w - vw * fit) * 0.5;
let oy = (wrap_h - vh * fit) * 0.5;
let wx = left + (cx - ox) / fit;
let wy = top + (cy - oy) / fit;
(wx, wy)
}
fn centroid(coords: &span>; 2) -> (f64, f64) {
if coords.is_empty() {
return (0.0, 0.0);
}
let n = coords.len() as f64;
let lon = coords.iter().map(|c| c[0]).sum::<f64>() / n;
let lat = coords.iter().map(|c| c[1]).sum::<f64>() / n;
(lon, lat)
}
fn status_color(s: &str) -> &'static str {
match s {
"idle" => "var(--cyber-green)",
"busy" => "var(--cyber-yellow)",
_ => "#555",
}
}
/// Ops map โ fleets rail + flats.
#[component]
pub fn ValleyConsole() -> impl IntoView {
view! { <MapConsole domains_board=false /> }
}
/// Domains board โ same camera, left rail = 21 cybics (draggable shill points).
#[component]
pub fn DomainsBoard() -> impl IntoView {
view! { <MapConsole domains_board=true /> }
}
#[component]
fn MapConsole(domains_board: bool) -> impl IntoView {
let map = load_map();
let map = std::sync::Arc::new(map);
let map_bbox = map.bbox.clone();
let base_domains = map.domains.clone();
let selected_flat = RwSignal::new({
let from_q = web_sys::window()
.and_then(|w| w.location().search().ok())
.and_then(|s| {
let s = s.trim_start_matches('?');
s.split('&').find_map(|pair| {
let mut it = pair.splitn(2, '=');
let k = it.next()?;
let v = it.next().unwrap_or("");
if k == "plot" && !v.is_empty() {
// plot ids are ascii (core-0, sinwood-1-laba, โฆ)
Some(v.replace('+', " "))
} else {
None
}
})
});
from_q
.filter(|id| map.phase0.iter().any(|f| f.id == *id))
.or_else(|| map.phase0.first().map(|f| f.id.clone()))
.or_else(|| Some("sinwood".into()))
});
let selected_fleet = RwSignal::new(Some("w-sutar".to_string()));
let selected_action = RwSignal::new("survey".to_string());
let intents = RwSignal::new(load_intents());
let next_id = RwSignal::new(
load_intents()
.iter()
.map(|i| i.id)
.max()
.unwrap_or(0)
.saturating_add(1),
);
let owned = RwSignal::new(load_owned());
let leased = RwSignal::new(
load_leases()
.into_iter()
.map(|l| l.flat_id)
.collect::<Vec<String>>(),
);
let flats = RwSignal::new(map.phase0.clone()); // live land geometry (split/merge)
let sheet = RwSignal::new(Sheet::None);
let buy_pick = RwSignal::new("cat-eye".to_string());
let robot_serial = RwSignal::new({
let max = load_owned()
.iter()
.filter_map(|r| {
r.name
.rsplit('-')
.next()
.and_then(|s| s.parse::<u32>().ok())
})
.max()
.unwrap_or(0);
max + 1
});
let merge_pick = RwSignal::new(None::<String>); // second flat for merge
// split controls: axis 0 = E/W (vertical line), 1 = N/S (horizontal line)
let split_axis = RwSignal::new(0usize);
let split_ratio = RwSignal::new(0.50_f64); // 0..1 across bbox
// left rail: fleets (ops) or domains (shill points)
let left_board = RwSignal::new(if domains_board {
LeftBoard::Domains
} else {
LeftBoard::Fleets
});
let domains = RwSignal::new(apply_domain_positions(base_domains.clone()));
let selected_domain = RwSignal::new(
domains
.get_untracked()
.first()
.map(|d| d.id.clone())
.or_else(|| Some("domain-ai".into())),
);
// live domain drag (id) โ positions persist to localStorage
let domain_drag = RwSignal::new(None::<String>);
let domain_did_drag = RwSignal::new(false);
// game map camera โ SVG viewBox zoom/pan (vector-sharp; never CSS scale)
// max zoom high enough for ~10 cm tile/hex planning on the ground
const MAP_W: f64 = 960.0;
const MAP_H: f64 = 720.0;
const MAP_PAD: f64 = 20.0;
let site_ew_m = site_span_m(&map.bbox).0;
let map_zoom = RwSignal::new(1.0_f64);
let map_center = RwSignal::new((MAP_W * 0.5, MAP_H * 0.5)); // world/SVG units
let map_hover = RwSignal::new(None::<(String, String, f64)>); // id, label, m2
let map_dragging = RwSignal::new(false);
// true once pointer moved past threshold โ suppress plot-click select after pan
let map_did_drag = RwSignal::new(false);
let map_drag_last = RwSignal::new((0.0_f64, 0.0_f64));
let map_wrap_ref = NodeRef::<leptos::html::Div>::new();
// focus camera on a domain id (used from list cards)
let zoom_by = move |factor: f64| {
map_zoom.update(|z| *z = (*z * factor).clamp(MAP_ZOOM_MIN, MAP_ZOOM_MAX));
};
let zoom_at = move |factor: f64, cx: f64, cy: f64| {
// keep world point under cursor stable while viewBox zooms
let z0 = map_zoom.get_untracked();
let z1 = (z0 * factor).clamp(MAP_ZOOM_MIN, MAP_ZOOM_MAX);
if (z1 - z0).abs() < 1e-9 {
return;
}
let c0 = map_center.get_untracked();
let (ww, wh) = if let Some(el) = map_wrap_ref.get_untracked() {
let r = el.get_bounding_client_rect();
(r.width(), r.height())
} else {
(MAP_W, MAP_H)
};
let (wx, wy) = client_to_world(cx, cy, ww, wh, c0, z0, MAP_W, MAP_H);
let vw1 = MAP_W / z1;
let vh1 = MAP_H / z1;
let fit1 = (ww / vw1).min(wh / vh1);
let ox1 = (ww - vw1 * fit1) * 0.5;
let oy1 = (wh - vh1 * fit1) * 0.5;
let left1 = wx - (cx - ox1) / fit1;
let top1 = wy - (cy - oy1) / fit1;
let c1 = (left1 + vw1 * 0.5, top1 + vh1 * 0.5);
map_zoom.set(z1);
map_center.set(c1);
};
let pan_by = move |dx_screen: f64, dy_screen: f64| {
let z = map_zoom.get_untracked().max(0.25);
let (ww, wh) = if let Some(el) = map_wrap_ref.get_untracked() {
let r = el.get_bounding_client_rect();
(r.width(), r.height())
} else {
(MAP_W, MAP_H)
};
let vw = MAP_W / z;
let vh = MAP_H / z;
let fit = (ww / vw).min(wh / vh).max(1e-9);
map_center.update(|(cx, cy)| {
*cx -= dx_screen / fit;
*cy -= dy_screen / fit;
});
};
let reset_cam = move || {
map_zoom.set(1.0);
map_center.set((MAP_W * 0.5, MAP_H * 0.5));
};
Effect::new(move |_| {
let title = if left_board.get() == LeftBoard::Domains {
"Cyberia โ domains ยท Gesing, Bali"
} else {
"Cyberia โ map ยท Gesing, Bali"
};
document().set_title(title);
});
let map_for_svg = map.clone();
let nav_active = if domains_board { "domains" } else { "map" };
// split clones so left-rail closure and map markers don't fight over ownership
let base_domains_rail = base_domains.clone();
let base_domains_map = base_domains.clone();
let map_bbox_rail = map_bbox.clone();
let map_bbox_drag = map_bbox.clone();
view! {
<div class="page-shell cyberia-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 || {
if left_board.get() == LeftBoard::Domains {
let n = domains.get().len();
format!("21 CYBICS ยท {n} DOMAINS ยท DRAG")
} else {
let n = flats.get().len();
format!("PHASE 0 ยท {n} PLOTS ยท GESING")
}
}}
</div>
<CyberiaNav active=nav_active />
</div>
</div>
</div>
<div class="cyberia-stage">
// LEFT โ FLEETS or DOMAINS
<section class="cyberia-panel cyberia-fleets">
<div class="cyberia-panel-h">
<div class="board-tabs">
<a
class=move || if left_board.get() == LeftBoard::Fleets { "board-tab on" } else { "board-tab" }
href="/map"
on:click=move |ev| {
if !domains_board {
ev.prevent_default();
left_board.set(LeftBoard::Fleets);
}
}
>"FLEETS"</a>
<a
class=move || if left_board.get() == LeftBoard::Domains { "board-tab on" } else { "board-tab" }
href="/domains"
on:click=move |ev| {
if domains_board {
ev.prevent_default();
left_board.set(LeftBoard::Domains);
}
}
>"DOMAINS"</a>
</div>
<span class="panel-sub">
{move || match left_board.get() {
LeftBoard::Fleets => format!("{} workers ยท {} machines", WORKERS.len(), MACHINES.len()),
LeftBoard::Domains => {
let n = domains.get().len();
format!("{n} cybics ยท drag on map")
}
}}
</span>
</div>
<div class="fleet-list">
{move || match left_board.get() {
LeftBoard::Domains => {
let list = domains.get();
let baked_reset = base_domains_rail.clone();
let bbox_list = map_bbox_rail.clone();
// group by triad โ question once per group, not on every card
let mut by_triad: HashMap<String, Vec<DomainMark>> = HashMap::new();
for d in list {
by_triad.entry(d.triad.clone()).or_default().push(d);
}
// stable order inside group: name alpha
for v in by_triad.values_mut() {
v.sort_by(|a, b| a.name.cmp(&b.name));
}
let mut groups: Vec<(String, Vec<DomainMark>)> = TRIAD_ORDER
.iter()
.filter_map(|t| {
by_triad
.remove(*t)
.map(|v| ((*t).to_string(), v))
})
.collect();
// any unknown triad tail
let mut rest: Vec<_> = by_triad.into_iter().collect();
rest.sort_by(|a, b| a.0.cmp(&b.0));
groups.extend(rest);
view! {
<div class="fleet-section">"CYBICS ยท BY TRIAD"</div>
<div class="domain-board-actions">
<button
class="act-pill"
type="button"
title="reset domain positions to baked map"
on:click=move |_| {
clear_domain_positions();
domains.set(baked_reset.clone());
}
>"RESET"</button>
<button
class="act-pill"
type="button"
title="log lon/lat JSON overrides to browser console"
on:click=move |_| {
let list = domains.get_untracked();
let mut pos = HashMap::new();
for d in &list {
if let Some(c) = d.coords.first() {
pos.insert(d.name.clone(), [c[0], c[1]]);
}
}
let s = serde_json::to_string_pretty(&pos).unwrap_or_else(|_| "{}".into());
web_sys::console::log_1(&s.into());
if let Some(w) = web_sys::window() {
let _ = w.alert_with_message(
"Domain positions logged to console (F12).\nAlso saved in localStorage.",
);
}
}
>"EXPORT"</button>
</div>
{groups.into_iter().map(|(triad, doms)| {
let color = triad_color(&triad);
let q = triad_question(&triad);
let triad_up = triad.to_uppercase();
let n = doms.len();
let bbox_group = bbox_list.clone();
view! {
<div class="domain-triad-group" style=format!("--dom-color: {color}")>
<div class="domain-triad-h">
<span class="domain-triad-name" style:color=color>{triad_up}</span>
<span class="domain-triad-q">{q}</span>
<span class="domain-triad-n">{format!("{n}")}</span>
</div>
<div class="domain-triad-cards">
{doms.into_iter().map(|dom| {
let id = dom.id.clone();
let id2 = id.clone();
let name = dom.name.clone();
let district = if dom.district.is_empty() {
dom.zone.clone()
} else {
dom.district.clone()
};
let color = triad_color(&dom.triad);
let bbox_focus = bbox_group.clone();
view! {
<button
class=move || {
let sel = selected_domain.get().as_deref() == Some(id.as_str());
format!("fleet-card domain-card domain-card-compact{}", if sel { " sel" } else { "" })
}
style=format!("--dom-color: {color}")
on:click=move |_| {
selected_domain.set(Some(id2.clone()));
if let Some(dom) = domains
.get_untracked()
.into_iter()
.find(|d| d.id == id2)
{
if let Some(c) = dom.coords.first() {
let (x, y) = project(
c[0],
c[1],
&bbox_focus,
MAP_W,
MAP_H,
MAP_PAD,
);
map_center.set((x, y));
if map_zoom.get_untracked() < 3.0 {
map_zoom.set(4.0);
}
}
}
}
>
<div class="fleet-top">
<span class="fleet-name" style:color=color>{name.to_uppercase()}</span>
<span class="fleet-status">{district.to_uppercase()}</span>
</div>
</button>
}
}).collect_view()}
</div>
</div>
}
}).collect_view()}
}.into_any()
}
LeftBoard::Fleets => view! {
<div class="fleet-section">"WORKERS ยท HARD FORCE"</div>
{WORKERS.iter().map(|f| render_fleet_card(f, selected_fleet)).collect_view()}
<div class="fleet-section">"MACHINES"</div>
{MACHINES.iter().map(|f| render_fleet_card(f, selected_fleet)).collect_view()}
// robots you bought this session
{move || owned.get().into_iter().map(|r| {
let id = r.id.clone();
let id2 = id.clone();
let kind_worker = r.kind == "worker";
let name = r.name.clone();
let role = r.role.clone();
let kind = r.kind.clone();
view! {
<button
class=move || {
let sel = selected_fleet.get().as_deref() == Some(id.as_str());
format!(
"fleet-card owned{}{}",
if sel { " sel" } else { "" },
if kind_worker { " worker" } else { " machine" },
)
}
on:click=move |_| selected_fleet.set(Some(id2.clone()))
>
<div class="fleet-top">
<span class="fleet-name">{name}</span>
<span class="fleet-status" style:color=status_color("idle")>"OWNED"</span>
</div>
<div class="fleet-role">{role}</div>
<div class="fleet-meta">
<span>{kind.to_uppercase()}</span>
<span>"BOUGHT"</span>
</div>
</button>
}
}).collect_view()}
}.into_any()
}}
</div>
<div class="cyberia-hint">
{move || match left_board.get() {
LeftBoard::Domains => "Select a domain ยท drag its glow on the map ยท positions save in this browser",
LeftBoard::Fleets => "Pick a fleet unit, a flat on the map, an action โ then commit intent. Or buy a robot / lease land.",
}}
</div>
</section>
// CENTER โ FLATS MAP (game camera: static viewport, wheel/keys zoom, drag pan)
<section class="cyberia-panel cyberia-flats">
<div class="cyberia-panel-h">
<span class="panel-kicker">"FLATS"</span>
<span class="panel-sub">
{format!(
"{} plots ยท {:.1} ha plots ยท {:.0} ha site ยท scroll zoom",
map.stats.plot_count.max(map.phase0.len() as u32),
map.stats.plot_ha,
map.stats.district_ha,
)}
</span>
<span class="map-zoom-readout" title="zoom ยท visible width (game scale)">
{move || {
let z = map_zoom.get();
let m = view_width_m(z, MAP_W, MAP_PAD, site_ew_m);
format_zoom_hud(z, m)
}}
</span>
</div>
<div
class="flat-map-wrap game-map"
node_ref=map_wrap_ref
tabindex="0"
on:wheel=move |ev| {
ev.prevent_default();
ev.stop_propagation();
let dy = ev.delta_y();
if dy == 0.0 { return; }
// slightly faster step so deep game zoom (ร1000) is reachable
let factor = if dy < 0.0 { 1.18 } else { 1.0 / 1.18 };
if let Some(el) = map_wrap_ref.get_untracked() {
let rect = el.get_bounding_client_rect();
let cx = ev.client_x() as f64 - rect.left();
let cy = ev.client_y() as f64 - rect.top();
zoom_at(factor, cx, cy);
} else {
zoom_by(factor);
}
}
on:keydown=move |ev| {
let key = ev.key();
let step = 36.0;
match key.as_str() {
"ArrowLeft" | "a" | "A" => { ev.prevent_default(); pan_by(step, 0.0); }
"ArrowRight" | "d" | "D" => { ev.prevent_default(); pan_by(-step, 0.0); }
"ArrowUp" | "w" | "W" => { ev.prevent_default(); pan_by(0.0, step); }
"ArrowDown" | "s" | "S" => { ev.prevent_default(); pan_by(0.0, -step); }
"+" | "=" => { ev.prevent_default(); zoom_by(1.25); }
"-" | "_" => { ev.prevent_default(); zoom_by(1.0 / 1.25); }
"0" => { ev.prevent_default(); reset_cam(); }
_ => {}
}
}
on:pointerdown=move |ev| {
// pan from anywhere on the map (plots, empty, places) โ
// filled flats used to block drag; click still selects if no pan
// domain-dot starts its own drag via stop_propagation
if ev.button() != 0 { return; }
if domain_drag.get_untracked().is_some() { return; }
let target = ev.target().and_then(|t| t.dyn_into::<web_sys::Element>().ok());
let on_ui = target
.as_ref()
.and_then(|el| el.closest(".map-zoom-rail, .map-cam-btn, .domain-dot").ok().flatten())
.is_some();
if on_ui {
return;
}
map_dragging.set(true);
map_did_drag.set(false);
map_drag_last.set((ev.client_x() as f64, ev.client_y() as f64));
if let Some(el) = map_wrap_ref.get_untracked() {
let _ = el.set_pointer_capture(ev.pointer_id());
}
}
on:pointermove=move |ev| {
// โโ domain drag (shill points) โโ
if let Some(dom_id) = domain_drag.get_untracked() {
let (ww, wh) = if let Some(el) = map_wrap_ref.get_untracked() {
let r = el.get_bounding_client_rect();
(r.width(), r.height())
} else {
(MAP_W, MAP_H)
};
let rect = map_wrap_ref
.get_untracked()
.map(|el| el.get_bounding_client_rect());
let (cx, cy) = if let Some(r) = rect {
(
ev.client_x() as f64 - r.left(),
ev.client_y() as f64 - r.top(),
)
} else {
(ev.client_x() as f64, ev.client_y() as f64)
};
let (lx, ly) = map_drag_last.get_untracked();
let dx = (ev.client_x() as f64) - lx;
let dy = (ev.client_y() as f64) - ly;
if !domain_did_drag.get_untracked() {
if dx * dx + dy * dy < 16.0 {
return;
}
domain_did_drag.set(true);
}
let (wx, wy) = client_to_world(
cx,
cy,
ww,
wh,
map_center.get_untracked(),
map_zoom.get_untracked(),
MAP_W,
MAP_H,
);
let (lon, lat) = unproject(wx, wy, &map_bbox_drag, MAP_W, MAP_H, MAP_PAD);
domains.update(|list| {
if let Some(d) = list.iter_mut().find(|d| d.id == dom_id) {
d.coords = vec![[lon, lat]];
}
});
map_drag_last.set((ev.client_x() as f64, ev.client_y() as f64));
return;
}
if !map_dragging.get_untracked() { return; }
let (lx, ly) = map_drag_last.get_untracked();
let cx = ev.client_x() as f64;
let cy = ev.client_y() as f64;
let dx = cx - lx;
let dy = cy - ly;
// ~5px threshold so a tap still counts as plot select
if !map_did_drag.get_untracked() {
if dx * dx + dy * dy < 25.0 {
return;
}
map_did_drag.set(true);
}
pan_by(dx, dy);
map_drag_last.set((cx, cy));
}
on:pointerup=move |_| {
if domain_drag.get_untracked().is_some() {
if domain_did_drag.get_untracked() {
save_domain_positions(&domains.get_untracked());
}
domain_drag.set(None);
domain_did_drag.set(false);
}
map_dragging.set(false);
}
on:pointercancel=move |_| {
domain_drag.set(None);
domain_did_drag.set(false);
map_dragging.set(false);
map_did_drag.set(false);
}
on:pointerleave=move |_| {
if !map_dragging.get_untracked() {
map_hover.set(None);
}
}
>
// left zoom rail (game-style) โ deep zoom for cm-scale tile planning
<div class="map-zoom-rail" aria-label="map zoom">
<button class="map-cam-btn" type="button" title="zoom in (to ~10cm tile scale)"
on:click=move |ev| { ev.stop_propagation(); zoom_by(1.35); }
>"+"</button>
<button class="map-cam-btn" type="button" title="zoom out"
on:click=move |ev| { ev.stop_propagation(); zoom_by(1.0 / 1.35); }
>"โ"</button>
<button class="map-cam-btn" type="button" title="reset view"
on:click=move |ev| { ev.stop_propagation(); reset_cam(); }
>"โ"</button>
<div class="map-cam-sep"></div>
<button class="map-cam-btn" type="button" title="pan left"
on:click=move |ev| { ev.stop_propagation(); pan_by(48.0, 0.0); }
>"โ"</button>
<button class="map-cam-btn" type="button" title="pan right"
on:click=move |ev| { ev.stop_propagation(); pan_by(-48.0, 0.0); }
>"โ"</button>
<button class="map-cam-btn" type="button" title="pan up"
on:click=move |ev| { ev.stop_propagation(); pan_by(0.0, 48.0); }
>"โ"</button>
<button class="map-cam-btn" type="button" title="pan down"
on:click=move |ev| { ev.stop_propagation(); pan_by(0.0, -48.0); }
>"โ"</button>
</div>
{
let m = map_for_svg.clone();
let m_places = map_for_svg.clone();
let m_cap = map_for_svg.clone();
let m_districts = map_for_svg.clone();
const W: f64 = 960.0;
const H: f64 = 720.0;
const PAD: f64 = 20.0;
view! {
<div class="map-world">
<svg
class="flat-map"
viewBox=move || map_view_box(map_center.get(), map_zoom.get(), W, H)
preserveAspectRatio="xMidYMid meet"
>
// full world backdrop (fixed world bounds)
<rect x="0" y="0" width=W height=H fill="#070707" />
{(0..12).map(|i| {
let x = PAD + i as f64 * (W - 2.0*PAD) / 11.0;
let y2 = H - PAD;
view! {
<line x1=x y1=PAD x2=x y2=y2 stroke="#121212" stroke-width="1" />
}
}).collect_view()}
{(0..9).map(|i| {
let y = PAD + i as f64 * (H - 2.0*PAD) / 8.0;
let x2 = W - PAD;
view! {
<line x1=PAD y1=y x2=x2 y2=y stroke="#121212" stroke-width="1" />
}
}).collect_view()}
// district outlines โ full site ~37 ha envelope
{m_districts.districts.iter().map(|d| {
let path = poly_path(&d.coords, &m_districts.bbox, W, H, PAD);
let (clon, clat) = centroid(&d.coords);
let (lx, ly) = project(clon, clat, &m_districts.bbox, W, H, PAD);
let label = d.name.to_uppercase();
view! {
<g class="district-poly" pointer-events="none">
<path
d=path
fill="rgba(255,255,255,0.015)"
stroke="rgba(255,255,255,0.12)"
stroke-width="1.25"
stroke-dasharray="4 3"
vector-effect="non-scaling-stroke"
/>
<text
class="district-label"
text-anchor="middle"
dominant-baseline="middle"
transform=move || screen_stable_tf(lx, ly, map_zoom.get())
>{label.clone()}</text>
</g>
}
}).collect_view()}
// ALL plots (interactive)
{move || {
let list = flats.get();
list.into_iter().map(|flat| {
let id_click = flat.id.clone();
let id_fill = flat.id.clone();
let id_stroke = flat.id.clone();
let id_sw = flat.id.clone();
let id_sw2 = flat.id.clone();
let id_cls = flat.id.clone();
let id_lab = flat.id.clone();
let id_hov = flat.id.clone();
let id_hov2 = flat.id.clone();
let zkey = zone_key(&flat).to_string();
let z_fill = zkey.clone();
let z_stroke = zkey.clone();
let z_hov = zkey.clone();
let label_hov = flat.name.to_uppercase();
let area = area_m2(&flat.coords);
let d = poly_path(&flat.coords, &m.bbox, W, H, PAD);
let (clon, clat) = centroid(&flat.coords);
let (lx, ly) = project(clon, clat, &m.bbox, W, H, PAD);
// labels only on selected plot (click) โ hover uses HUD tooltip
let base_label = flat.name.to_uppercase();
let d_under = d.clone();
view! {
<g class="flat-poly"
on:click=move |ev| {
// after a pan, browser still fires click โ ignore it
if map_did_drag.get_untracked() {
map_did_drag.set(false);
ev.stop_propagation();
return;
}
ev.stop_propagation();
selected_flat.set(Some(id_click.clone()));
}
on:pointerenter=move |_| {
map_hover.set(Some((id_hov.clone(), label_hov.clone(), area)));
}
on:pointerleave=move |_| {
map_hover.update(|h| {
if h.as_ref().map(|t| t.0.as_str()) == Some(id_hov2.as_str()) {
*h = None;
}
});
}
>
// dark under-stroke: thin seam idle, wide halo on hover/select
<path
d=d_under
fill="none"
stroke="#000000"
stroke-width=move || {
let sel = selected_flat.get().as_deref() == Some(id_sw2.as_str());
let hov = map_hover.get().as_ref().map(|t| t.0.as_str()) == Some(id_sw2.as_str());
// non-scaling-stroke: screen px, constant under viewBox zoom
if sel { "4.0" } else if hov { "3.4" } else { "1.35" }
}
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
class="flat-path-under"
pointer-events="none"
/>
<path
d=d
fill=move || {
let sel = selected_flat.get().as_deref() == Some(id_fill.as_str());
let hov = map_hover.get().as_ref().map(|t| t.0.as_str()) == Some(id_fill.as_str());
if hov && !sel {
zone_hover_fill(&z_hov).to_string()
} else {
flat_fill(&z_fill, sel).to_string()
}
}
stroke=move || {
let sel = selected_flat.get().as_deref() == Some(id_stroke.as_str());
let hov = map_hover.get().as_ref().map(|t| t.0.as_str()) == Some(id_stroke.as_str());
flat_stroke_col(&z_stroke, sel, hov).to_string()
}
stroke-width=move || {
let sel = selected_flat.get().as_deref() == Some(id_sw.as_str());
let hov = map_hover.get().as_ref().map(|t| t.0.as_str()) == Some(id_sw.as_str());
// glow only when hot โ idle is a hairline seam
if sel { "2.2" } else if hov { "1.9" } else { "0.75" }
}
stroke-linejoin="round"
stroke-linecap="round"
paint-order="stroke fill"
vector-effect="non-scaling-stroke"
class=move || {
let sel = selected_flat.get().as_deref() == Some(id_cls.as_str());
let hov = map_hover.get().as_ref().map(|t| t.0.as_str()) == Some(id_cls.as_str());
format!(
"flat-path{}{}",
if sel { " is-sel" } else { "" },
if hov { " is-hov" } else { "" },
)
}
/>
{move || {
let sel = selected_flat.get().as_deref() == Some(id_lab.as_str());
if !sel {
return view! { <g></g> }.into_any();
}
let text = if leased.get().iter().any(|x| x == &id_lab) {
format!("{base_label} ยท LEASED")
} else {
base_label.clone()
};
view! {
<text
class="flat-label"
text-anchor="middle"
dominant-baseline="middle"
transform=move || screen_stable_tf(lx, ly, map_zoom.get())
>{text}</text>
}.into_any()
}}
</g>
}
}).collect_view()
}}
{m_places.places.iter().map(|p| {
if p.coords.is_empty() { return view! { <g></g> }.into_any(); }
let (x, y) = project(p.coords[0][0], p.coords[0][1], &m_places.bbox, W, H, PAD);
let name = p.name.clone();
view! {
// dots + names stay screen-stable under camera zoom
<g
class="place-dot"
transform=move || screen_stable_tf(x, y, map_zoom.get())
>
<circle cx="0" cy="0" r="3" fill="#3a3a3a" stroke="#777" stroke-width="0.9" />
<text x="0" y="-7" text-anchor="middle" class="place-label">{name}</text>
</g>
}.into_any()
}).collect_view()}
// 21 cybics domain shill points โ draggable; node graph is stable (pos from signal)
{
let bbox = m_places.bbox.clone();
// meta from baked list (ids/names fixed); coords live in `domains` signal
let domain_meta = base_domains_map.clone();
domain_meta.iter().map(|dom| {
let id = dom.id.clone();
let id_cls = id.clone();
let id_tf = id.clone();
let id_down = id.clone();
let id_r = id.clone();
let id_sw = id.clone();
let name = dom.name.clone();
let triad = dom.triad.clone();
let q = dom.question.clone();
let district = if dom.district.is_empty() {
dom.zone.clone()
} else {
dom.district.clone()
};
let color = triad_color(&dom.triad);
let title = format!(
"{} ยท {} ยท {}\n{} ยท drag to move",
triad.to_uppercase(),
name,
district.to_uppercase(),
q
);
let bbox2 = bbox.clone();
view! {
<g
class=move || {
let sel = selected_domain.get().as_deref() == Some(id_cls.as_str());
let dragging = domain_drag.get().as_deref() == Some(id_cls.as_str());
format!(
"domain-dot{}{}",
if sel { " is-sel" } else { "" },
if dragging { " is-drag" } else { "" },
)
}
transform=move || {
let (x, y) = domains
.get()
.into_iter()
.find(|d| d.id == id_tf)
.and_then(|d| d.coords.first().copied())
.map(|c| project(c[0], c[1], &bbox2, W, H, PAD))
.unwrap_or((0.0, 0.0));
screen_stable_tf(x, y, map_zoom.get())
}
on:pointerdown=move |ev| {
ev.stop_propagation();
ev.prevent_default();
if ev.button() != 0 { return; }
selected_domain.set(Some(id_down.clone()));
domain_drag.set(Some(id_down.clone()));
domain_did_drag.set(false);
map_dragging.set(false);
map_drag_last.set((ev.client_x() as f64, ev.client_y() as f64));
if let Some(el) = map_wrap_ref.get_untracked() {
let _ = el.set_pointer_capture(ev.pointer_id());
}
}
>
<title>{title.clone()}</title>
<circle
cx="0"
cy="0"
r="10"
fill="transparent"
class="domain-hit"
/>
<circle
cx="0"
cy="0"
r=move || {
let sel = selected_domain.get().as_deref() == Some(id_r.as_str());
if sel { "7.5" } else { "5.5" }
}
fill=color
fill-opacity="0.22"
stroke=color
stroke-width=move || {
let sel = selected_domain.get().as_deref() == Some(id_sw.as_str());
if sel { "2.0" } else { "1.4" }
}
class="domain-ring"
/>
<circle
cx="0"
cy="0"
r="2.4"
fill=color
class="domain-core"
/>
<text
x="0"
y="-9"
text-anchor="middle"
class="domain-label"
fill=color
>{name.clone()}</text>
</g>
}
.into_any()
}).collect_view()
}
<text
class="map-caption"
text-anchor="start"
transform=move || screen_stable_tf(PAD, H - 10.0, map_zoom.get())
>
{format!(
"N โ ยท {} plots ยท {:.1} ha plots / {:.0} ha site ยท 21 domains",
m_cap.stats.plot_count,
m_cap.stats.plot_ha,
m_cap.stats.district_ha,
)}
</text>
</svg>
</div>
}
}
// hover tooltip (game HUD)
{move || map_hover.get().map(|(id, label, m2)| {
let leased_tag = if leased.get().iter().any(|x| x == &id) { " ยท LEASED" } else { "" };
let sel = selected_flat.get().as_deref() == Some(id.as_str());
view! {
<div class="map-tooltip">
<div class="mt-name">{format!("{label}{leased_tag}")}</div>
<div class="mt-area">{fmt_area_m2(m2)}</div>
<div class="mt-hint">{if sel { "SELECTED" } else { "click to select" }}</div>
</div>
}
})}
<div class="map-hud-hint">
{move || if left_board.get() == LeftBoard::Domains {
"drag domain glows to place ยท pan empty space ยท scroll zoom ยท saved in browser"
} else {
"drag pan ยท scroll zoom ยท drag domain glows ยท /domains for domain board"
}}
</div>
</div>
<div class="flat-legend">
<span class="leg sin">"SINWOOD"</span>
<span class="leg avalon">"AVALON"</span>
<span class="leg ether">"ROCKETS"</span>
<span class="leg asgard">"ASGARD"</span>
<span class="leg edem">"EDEM"</span>
<span class="leg dim">"21 domains ยท drag to place ยท /domains board"</span>
</div>
</section>
// RIGHT โ 3D + INTENT
<section class="cyberia-panel cyberia-right">
<div class="cyberia-panel-h">
<span class="panel-kicker">"RENDER"</span>
<span class="panel-sub">"flat ยท robot ยท 3d"</span>
</div>
<div class="render-3d">
{move || {
// โโ flat โโ
let fid = selected_flat.get().unwrap_or_else(|| "sinwood".into());
let list = flats.get();
let flat = list.iter().find(|f| f.id == fid);
let flat_name = flat
.map(|f| f.name.to_uppercase())
.unwrap_or_else(|| fid.to_uppercase());
let flat_kind = flat.map(|f| f.kind.clone()).unwrap_or_default();
let n = flat.map(|f| f.coords.len()).unwrap_or(0);
let land_hue = if fid.contains("avalon") {
"var(--cyber-orange)"
} else if fid.contains("sinwood") {
"var(--cyber-green)"
} else {
"var(--cyber-cyan)"
};
let leased_tag = if leased.get().iter().any(|x| x == &fid) {
" ยท LEASED"
} else {
""
};
// โโ robot / worker โโ
let rid = selected_fleet.get();
let (bot_name, bot_kind, bot_role, bot_status, bot_owned) = rid
.as_ref()
.and_then(|id| {
stock_fleet(id).map(|f| {
(
f.name.to_string(),
f.kind.to_string(),
f.role.to_string(),
f.status.to_string(),
false,
)
}).or_else(|| {
owned.get().into_iter().find(|r| &r.id == id).map(|r| {
(r.name, r.kind, r.role, "idle".into(), true)
})
})
})
.unwrap_or_else(|| {
("โ".into(), "none".into(), "no unit selected".into(), "โ".into(), false)
});
let is_worker = bot_kind == "worker";
let bot_hue = if bot_kind == "none" {
"#444"
} else if is_worker {
"var(--cyber-cyan)"
} else {
"var(--cyber-orange)"
};
let bot_cls = if bot_kind == "none" {
"bot-figure empty"
} else if is_worker {
"bot-figure worker"
} else {
"bot-figure machine"
};
view! {
<div class="render-pair">
// LAND
<div class="prism-scene">
<div class="render-tag">"FLAT"</div>
<div class="prism" style:--prism-color=land_hue>
<div class="prism-face prism-top"></div>
<div class="prism-face prism-front"></div>
<div class="prism-face prism-side"></div>
</div>
<div class="prism-meta">
<div class="prism-name">{format!("{flat_name}{leased_tag}")}</div>
<div class="prism-sub">{format!("{flat_kind} ยท {n} verts")}</div>
<div class="prism-sub">"Gesing hold"</div>
</div>
</div>
// ROBOT
<div class="prism-scene bot-scene">
<div class="render-tag">"ROBOT"</div>
<div class=bot_cls style:--bot-color=bot_hue>
<div class="bot-head"></div>
<div class="bot-body">
<div class="bot-chest"></div>
<div class="bot-arm bot-arm-l"></div>
<div class="bot-arm bot-arm-r"></div>
</div>
<div class="bot-legs">
<div class="bot-leg"></div>
<div class="bot-leg"></div>
</div>
<div class="bot-glow"></div>
</div>
<div class="prism-meta">
<div class="prism-name" style:color=bot_hue>{bot_name}</div>
<div class="prism-sub">{bot_role}</div>
<div class="prism-sub">
{
let status_lbl = if bot_owned {
"OWNED".to_string()
} else {
bot_status.to_uppercase()
};
format!("{} ยท {}", bot_kind.to_uppercase(), status_lbl)
}
</div>
</div>
</div>
</div>
}
}}
</div>
<div class="cyberia-panel-h" style="margin-top: 12px;">
<span class="panel-kicker">"INTENT"</span>
<span class="panel-sub">"soft3 write"</span>
</div>
<div class="intent-form">
<div class="intent-row">
<span class="intent-k">"fleet"</span>
<span class="intent-v">
{move || selected_fleet.get()
.and_then(|id| {
stock_fleet(&id)
.map(|f| f.name.to_string())
.or_else(|| {
owned
.get_untracked()
.into_iter()
.find(|r| r.id == id)
.map(|r| r.name)
})
})
.unwrap_or_else(|| "โ".into())}
</span>
</div>
<div class="intent-row">
<span class="intent-k">"flat"</span>
<span class="intent-v">
{move || selected_flat.get().unwrap_or_else(|| "โ".into()).to_uppercase()}
</span>
</div>
<div class="intent-actions">
{ACTIONS.iter().map(|(a, _)| {
let act = (*a).to_string();
let act2 = act.clone();
let locked = *a == "build";
view! {
<button
class=move || {
let sel = selected_action.get() == act;
format!("act-pill{}{}", if sel { " sel" } else { "" }, if locked { " locked" } else { "" })
}
disabled=locked
on:click=move |_| {
if !locked {
selected_action.set(act2.clone());
}
}
>{a.to_uppercase()}</button>
}
}).collect_view()}
</div>
<button
class="intent-commit"
on:click=move |_| {
let Some(fleet) = selected_fleet.get() else { return };
let Some(flat) = selected_flat.get() else { return };
let action = selected_action.get();
// soft3: offline stock fleets cannot take intent
if stock_fleet(&fleet)
.is_some_and(|f| f.status == "offline" || f.phase > 0)
{
return;
}
let fleet_name = stock_fleet(&fleet)
.map(|f| f.name.to_string())
.or_else(|| {
owned
.get_untracked()
.into_iter()
.find(|r| r.id == fleet)
.map(|r| r.name)
})
.unwrap_or(fleet);
let id = next_id.get();
next_id.set(id + 1);
intents.update(|q| {
q.insert(
0,
IntentRec {
id,
fleet: fleet_name,
action,
flat,
},
);
save_intents(q);
});
}
>
"COMMIT INTENT"
</button>
<div class="intent-queue">
{move || {
let q = intents.get();
if q.is_empty() {
return view! {
<div class="intent-empty">"no intents yet โ buy, lease, or assign a fleet"</div>
}.into_any();
}
view! {
<div class="intent-list">
{q.into_iter().take(10).map(|it| {
let act_cls = match it.action.as_str() {
"buy" => "ii-act buy",
"lease" => "ii-act lease",
"split" => "ii-act split",
"merge" => "ii-act merge",
_ => "ii-act",
};
view! {
<div class="intent-item">
<span class="ii-id">{format!("#{:03}", it.id)}</span>
<span class="ii-fleet">{it.fleet}</span>
<span class=act_cls>{it.action.to_uppercase()}</span>
<span class="ii-flat">{it.flat.to_uppercase()}</span>
</div>
}
}).collect_view()}
</div>
}.into_any()
}}
</div>
</div>
</section>
</div>
// โโ sheets: BUY ROBOT / LEASE LAND โโ
{move || match sheet.get() {
Sheet::None => view! { <div></div> }.into_any(),
Sheet::BuyRobot => view! {
<div class="cyberia-sheet-backdrop" on:click=move |_| sheet.set(Sheet::None)>
<div class="cyberia-sheet" on:click=move |ev| ev.stop_propagation()>
<div class="sheet-h">
<span class="panel-kicker">"BUY ROBOT"</span>
<button class="sheet-x" on:click=move |_| sheet.set(Sheet::None)>"โ"</button>
</div>
<p class="sheet-note">
"Phase 0 catalog โ soft3 local intent. No payment rail yet; purchase queues an intent and adds the unit to your fleet."
</p>
<div class="sheet-catalog">
{ROBOT_CATALOG.iter().map(|r| {
let id = r.id.to_string();
let id2 = id.clone();
let kind_worker = r.kind == "worker";
view! {
<button
class=move || {
let sel = buy_pick.get() == id;
format!(
"fleet-card catalog{}{}",
if sel { " sel" } else { "" },
if kind_worker { " worker" } else { " machine" },
)
}
on:click=move |_| buy_pick.set(id2.clone())
>
<div class="fleet-top">
<span class="fleet-name">{r.name}</span>
<span class="fleet-status" style:color="var(--cyber-yellow)">"FOR SALE"</span>
</div>
<div class="fleet-role">{r.role}</div>
<div class="fleet-meta">
<span>{r.kind.to_uppercase()}</span>
<span>"P0"</span>
</div>
</button>
}
}).collect_view()}
</div>
<button
class="intent-commit"
on:click=move |_| {
let pick = buy_pick.get();
let Some(cat) = ROBOT_CATALOG.iter().find(|r| r.id == pick) else { return };
let n = robot_serial.get();
robot_serial.set(n + 1);
let unit_id = format!("own-{}-{}", cat.name.to_lowercase(), n);
let unit_name = format!("{}-{:02}", cat.name, n);
owned.update(|v| {
v.push(OwnedRobot {
id: unit_id.clone(),
name: unit_name.clone(),
kind: cat.kind.to_string(),
role: cat.role.to_string(),
});
save_owned(v);
});
selected_fleet.set(Some(unit_id));
let id = next_id.get();
next_id.set(id + 1);
intents.update(|q| {
q.insert(
0,
IntentRec {
id,
fleet: unit_name,
action: "buy".into(),
flat: "market".into(),
},
);
save_intents(q);
});
debit_cx(15.0, "robots", "unit buy ยท map");
sheet.set(Sheet::None);
}
>
"CONFIRM BUY"
</button>
</div>
</div>
}.into_any(),
Sheet::LeaseLand => view! {
<div class="cyberia-sheet-backdrop" on:click=move |_| sheet.set(Sheet::None)>
<div class="cyberia-sheet" on:click=move |ev| ev.stop_propagation()>
<div class="sheet-h">
<span class="panel-kicker">"LEASE LAND"</span>
<button class="sheet-x" on:click=move |_| sheet.set(Sheet::None)>"โ"</button>
</div>
<p class="sheet-note">
"Phase 0 flats โ century-index denominated later. Soft3 local intent only; no closed payment backend."
</p>
<div class="sheet-catalog">
{move || flats.get().into_iter().map(|f| {
let id_cls = f.id.clone();
let id_dis = f.id.clone();
let id_click = f.id.clone();
let id_col = f.id.clone();
let id_lab = f.id.clone();
let name = f.name.to_uppercase();
let kind = f.kind.clone();
view! {
<button
class=move || {
let sel = selected_flat.get().as_deref() == Some(id_cls.as_str());
let taken = leased.get().iter().any(|x| x == &id_cls);
format!(
"fleet-card catalog flat-pick{}{}",
if sel { " sel" } else { "" },
if taken { " offline" } else { "" },
)
}
disabled=move || leased.get().iter().any(|x| x == &id_dis)
on:click=move |_| selected_flat.set(Some(id_click.clone()))
>
<div class="fleet-top">
<span class="fleet-name">{name.clone()}</span>
<span class="fleet-status" style:color=move || {
if leased.get().iter().any(|x| x == &id_col) {
"var(--cyber-yellow)"
} else {
"var(--cyber-green)"
}
}>
{move || {
if leased.get().iter().any(|x| x == &id_lab) {
"LEASED"
} else {
"OPEN"
}
}}
</span>
</div>
<div class="fleet-role">{format!("{kind} ยท Gesing ยท phase 0")}</div>
<div class="fleet-meta">
<span>"FLAT"</span>
<span>"CX later"</span>
</div>
</button>
}
}).collect_view()}
</div>
<button
class="intent-commit lease"
on:click=move |_| {
let Some(flat) = selected_flat.get() else { return };
if leased.get_untracked().iter().any(|x| x == &flat) {
return;
}
leased.update(|v| v.push(flat.clone()));
let (name, zone) = flats
.get_untracked()
.into_iter()
.find(|f| f.id == flat)
.map(|f| (f.name, f.zone))
.unwrap_or_else(|| (flat.clone(), String::new()));
let mut full = load_leases();
if !full.iter().any(|l| l.flat_id == flat) {
full.push(Lease {
flat_id: flat.clone(),
flat_name: name,
zone,
});
save_leases(&full);
}
let id = next_id.get();
next_id.set(id + 1);
intents.update(|q| {
q.insert(
0,
IntentRec {
id,
fleet: "YOU".into(),
action: "lease".into(),
flat: flat.clone(),
},
);
save_intents(q);
});
debit_cx(10.0, "land", "lease ยท map");
sheet.set(Sheet::None);
}
>
"CONFIRM LEASE"
</button>
</div>
</div>
}.into_any(),
Sheet::SplitLand => view! {
<div class="cyberia-sheet-backdrop" on:click=move |_| sheet.set(Sheet::None)>
<div class="cyberia-sheet sheet-wide" on:click=move |ev| ev.stop_propagation()>
<div class="sheet-h">
<span class="panel-kicker">"SPLIT LAND"</span>
<button class="sheet-x" on:click=move |_| sheet.set(Sheet::None)>"โ"</button>
</div>
<p class="sheet-note">
"Pick a flat, set cut direction and position. Areas are geodesic-approx mยฒ (equirectangular). Soft3 local intent."
</p>
<div class="split-pick-label">"1 ยท SELECT FLAT"</div>
<div class="sheet-catalog sheet-catalog-compact">
{move || flats.get().into_iter().map(|f| {
let id = f.id.clone();
let id2 = id.clone();
let name = f.name.to_uppercase();
let a = area_m2(&f.coords);
view! {
<button
class=move || {
let sel = selected_flat.get().as_deref() == Some(id.as_str());
format!("fleet-card catalog flat-pick{}", if sel { " sel" } else { "" })
}
on:click=move |_| {
selected_flat.set(Some(id2.clone()));
split_ratio.set(0.50);
}
>
<div class="fleet-top">
<span class="fleet-name">{name}</span>
<span class="fleet-status" style:color="var(--cyber-cyan)">{fmt_area_m2(a)}</span>
</div>
</button>
}
}).collect_view()}
</div>
<div class="split-pick-label">"2 ยท CUT DIRECTION"</div>
<div class="split-dir-row">
<button
class=move || if split_axis.get() == 0 { "act-pill sel" } else { "act-pill" }
on:click=move |_| split_axis.set(0)
>"โ E / W ยท vertical cut"</button>
<button
class=move || if split_axis.get() == 1 { "act-pill sel" } else { "act-pill" }
on:click=move |_| split_axis.set(1)
>"โ N / S ยท horizontal cut"</button>
</div>
<div class="split-pick-label">
{move || {
if split_axis.get() == 0 {
"3 ยท POSITION (west โ โ east)"
} else {
"3 ยท POSITION (south โ โ north)"
}
}}
</div>
<div class="split-slider-row">
<input
type="range"
min="5"
max="95"
step="1"
class="split-range"
prop:value=move || format!("{:.0}", split_ratio.get() * 100.0)
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse::<f64>() {
split_ratio.set((v / 100.0).clamp(0.05, 0.95));
}
}
/>
<span class="split-ratio-lbl">
{move || format!("{:.0}%", split_ratio.get() * 100.0)}
</span>
</div>
// live preview metrics + mini map
{move || {
let fid = selected_flat.get();
let axis = split_axis.get();
let ratio = split_ratio.get();
let list = flats.get();
let Some(src) = fid.as_ref().and_then(|id| list.iter().find(|f| &f.id == id).cloned()) else {
return view! {
<div class="split-metrics empty">"select a flat to preview the cut"</div>
}.into_any();
};
let total = area_m2(&src.coords);
let span = bbox_span_m(&src.coords, axis);
let offset = span * ratio;
let preview = split_flat_at(&src, axis, ratio);
let (ok, a_m2, b_m2, a_coords, b_coords) = match &preview {
Some((a, b, _)) => (
true,
area_m2(&a.coords),
area_m2(&b.coords),
a.coords.clone(),
b.coords.clone(),
),
None => (false, 0.0, 0.0, vec![], vec![]),
};
let pct_a = if total > 0.0 { a_m2 / total * 100.0 } else { 0.0 };
let pct_b = if total > 0.0 { b_m2 / total * 100.0 } else { 0.0 };
let side_a = if axis == 0 { "WEST (A)" } else { "SOUTH (A)" };
let side_b = if axis == 0 { "EAST (B)" } else { "NORTH (B)" };
let (min_lon, max_lon, min_lat, max_lat) = poly_bbox(&src.coords);
// mini viewBox
const MW: f64 = 320.0;
const MH: f64 = 180.0;
const MP: f64 = 12.0;
let bbox = BBox { min_lon, max_lon, min_lat, max_lat };
let path_src = poly_path(&src.coords, &bbox, MW, MH, MP);
let path_a = poly_path(&a_coords, &bbox, MW, MH, MP);
let path_b = poly_path(&b_coords, &bbox, MW, MH, MP);
let (cut_x1, cut_y1, cut_x2, cut_y2) = if axis == 0 {
let t = min_lon + ratio * (max_lon - min_lon);
let (x, y1) = project(t, max_lat, &bbox, MW, MH, MP);
let (_, y2) = project(t, min_lat, &bbox, MW, MH, MP);
(x, y1, x, y2)
} else {
let t = min_lat + ratio * (max_lat - min_lat);
let (x1, y) = project(min_lon, t, &bbox, MW, MH, MP);
let (x2, _) = project(max_lon, t, &bbox, MW, MH, MP);
(x1, y, x2, y)
};
view! {
<div class="split-preview">
<svg class="split-mini" viewBox=format!("0 0 {MW} {MH}")>
<path d=path_src fill="rgba(255,255,255,0.03)" stroke="#333" stroke-width="1" />
{ok.then(|| view! {
<path d=path_a fill="rgba(0,229,255,0.28)" stroke="var(--cyber-cyan)" stroke-width="1.5" />
<path d=path_b fill="rgba(255,0,255,0.22)" stroke="var(--cyber-magenta)" stroke-width="1.5" />
<line x1=cut_x1 y1=cut_y1 x2=cut_x2 y2=cut_y2
stroke="var(--cyber-yellow)" stroke-width="2"
stroke-dasharray="4 3" />
})}
</svg>
<div class="split-metrics">
<div class="sm-row total">
<span>"TOTAL"</span>
<span>{fmt_area_m2(total)}</span>
</div>
<div class="sm-row a">
<span>{side_a}</span>
<span>{if ok { format!("{} ยท {:.1}%", fmt_area_m2(a_m2), pct_a) } else { "โ".into() }}</span>
</div>
<div class="sm-row b">
<span>{side_b}</span>
<span>{if ok { format!("{} ยท {:.1}%", fmt_area_m2(b_m2), pct_b) } else { "โ".into() }}</span>
</div>
<div class="sm-row dim">
<span>"cut offset"</span>
<span>{format!("{:.1} m from {}", offset, if axis == 0 { "west" } else { "south" })}</span>
</div>
<div class="sm-row dim">
<span>"bbox span"</span>
<span>{format!("{:.1} m", span)}</span>
</div>
{(!ok).then(|| view! {
<div class="sm-row warn">"cut invalid โ move slider (sliver or empty half)"</div>
})}
</div>
</div>
}.into_any()
}}
<button
class="intent-commit split"
on:click=move |_| {
let Some(fid) = selected_flat.get() else { return };
let list = flats.get_untracked();
let Some(src) = list.iter().find(|f| f.id == fid).cloned() else { return };
let axis = split_axis.get_untracked();
let ratio = split_ratio.get_untracked();
let Some((a, b, _)) = split_flat_at(&src, axis, ratio) else { return };
let a_m2 = area_m2(&a.coords);
let b_m2 = area_m2(&b.coords);
let a_id = a.id.clone();
flats.update(|v| {
v.retain(|f| f.id != fid);
v.push(a);
v.push(b);
});
leased.update(|v| v.retain(|x| x != &fid));
{
let mut full = load_leases();
full.retain(|l| l.flat_id != fid);
save_leases(&full);
}
selected_flat.set(Some(a_id));
let id = next_id.get();
next_id.set(id + 1);
intents.update(|q| {
q.insert(
0,
IntentRec {
id,
fleet: "YOU".into(),
action: "split".into(),
flat: format!(
"{fid} โ {:.0}+{:.0} mยฒ",
a_m2, b_m2
),
},
);
save_intents(q);
});
sheet.set(Sheet::None);
}
>
"CONFIRM SPLIT"
</button>
</div>
</div>
}.into_any(),
Sheet::MergeLand => view! {
<div class="cyberia-sheet-backdrop" on:click=move |_| {
merge_pick.set(None);
sheet.set(Sheet::None);
}>
<div class="cyberia-sheet" on:click=move |ev| ev.stop_propagation()>
<div class="sheet-h">
<span class="panel-kicker">"MERGE LAND"</span>
<button class="sheet-x" on:click=move |_| {
merge_pick.set(None);
sheet.set(Sheet::None);
}>"โ"</button>
</div>
<p class="sheet-note">
"Pick two flats: first is primary (A), second is B. Merge into one hold. Soft3 local intent."
</p>
<div class="intent-row" style="margin-bottom:8px;">
<span class="intent-k">"A"</span>
<span class="intent-v">
{move || selected_flat.get().unwrap_or_else(|| "โ".into()).to_uppercase()}
</span>
</div>
<div class="intent-row" style="margin-bottom:10px;">
<span class="intent-k">"B"</span>
<span class="intent-v">
{move || merge_pick.get().unwrap_or_else(|| "โ".into()).to_uppercase()}
</span>
</div>
<div class="sheet-catalog">
{move || flats.get().into_iter().map(|f| {
let id = f.id.clone();
let id2 = id.clone();
let name = f.name.to_uppercase();
view! {
<button
class=move || {
let a = selected_flat.get().as_deref() == Some(id.as_str());
let b = merge_pick.get().as_deref() == Some(id.as_str());
format!(
"fleet-card catalog flat-pick{}{}",
if a { " sel" } else { "" },
if b { " merge-b" } else { "" },
)
}
on:click=move |_| {
let cur_a = selected_flat.get_untracked();
if cur_a.as_deref() == Some(id2.as_str()) {
return;
}
if cur_a.is_none() {
selected_flat.set(Some(id2.clone()));
} else if merge_pick.get_untracked().as_deref() == Some(id2.as_str()) {
merge_pick.set(None);
} else {
merge_pick.set(Some(id2.clone()));
}
}
>
<div class="fleet-top">
<span class="fleet-name">{name}</span>
<span class="fleet-status" style:color="var(--cyber-magenta)">"MERGE"</span>
</div>
<div class="fleet-role">"1st click = A ยท 2nd = B"</div>
</button>
}
}).collect_view()}
</div>
<button
class="intent-commit merge"
on:click=move |_| {
let Some(a_id) = selected_flat.get() else { return };
let Some(b_id) = merge_pick.get() else { return };
if a_id == b_id { return; }
let list = flats.get_untracked();
let Some(a) = list.iter().find(|f| f.id == a_id).cloned() else { return };
let Some(b) = list.iter().find(|f| f.id == b_id).cloned() else { return };
let merged = merge_flats(&a, &b);
let mid = merged.id.clone();
flats.update(|v| {
v.retain(|f| f.id != a_id && f.id != b_id);
v.push(merged);
});
leased.update(|v| {
v.retain(|x| x != &a_id && x != &b_id);
});
{
let mut full = load_leases();
full.retain(|l| l.flat_id != a_id && l.flat_id != b_id);
save_leases(&full);
}
selected_flat.set(Some(mid.clone()));
merge_pick.set(None);
let id = next_id.get();
next_id.set(id + 1);
intents.update(|q| {
q.insert(
0,
IntentRec {
id,
fleet: "YOU".into(),
action: "merge".into(),
flat: format!("{a_id}+{b_id}"),
},
);
save_intents(q);
});
sheet.set(Sheet::None);
}
>
"CONFIRM MERGE"
</button>
</div>
</div>
}.into_any(),
}}
<div class="search-dock cyberia-dock">
<span class="dock-count">
{move || {
let n = intents.get().len();
let m = flats.get().len();
let o = owned.get().len();
let l = leased.get().len();
format!(
"{n} intents ยท {m} flats ยท {}w+{}m+{o} fleets ยท {l} leases",
WORKERS.len(),
MACHINES.len(),
)
}}
</span>
<div class="cyberia-cta-bar dock-ctas">
<button class="cta-btn cta-buy cta-lg" on:click=move |_| sheet.set(Sheet::BuyRobot)>
<span class="cta-ico">"๐ค"</span>
<span class="cta-copy">
<span class="cta-title">"BUY ROBOT"</span>
<span class="cta-sub">"add a unit to your fleet"</span>
</span>
</button>
<button class="cta-btn cta-lease cta-lg" on:click=move |_| sheet.set(Sheet::LeaseLand)>
<span class="cta-ico">"๐บ"</span>
<span class="cta-copy">
<span class="cta-title">"LEASE LAND"</span>
<span class="cta-sub">"hold a phase-0 flat"</span>
</span>
</button>
<button class="cta-btn cta-split cta-lg cta-bold" on:click=move |_| {
split_ratio.set(0.50);
sheet.set(Sheet::SplitLand);
}>
<span class="cta-ico">"โ"</span>
<span class="cta-copy">
<span class="cta-title">"SPLIT LAND"</span>
<span class="cta-sub">"precise cut ยท mยฒ preview"</span>
</span>
</button>
<button class="cta-btn cta-merge cta-lg cta-bold" on:click=move |_| {
merge_pick.set(None);
sheet.set(Sheet::MergeLand);
}>
<span class="cta-ico">"โง"</span>
<span class="cta-copy">
<span class="cta-title">"MERGE LAND"</span>
<span class="cta-sub">"join two flats"</span>
</span>
</button>
</div>
<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>
}
}
//! Experimental cyberia console: fleets & flats.
//!
//! soft3 compliance (experimental surface):
//! - intent is first-class (assign fleet ร action ร flat)
//! - open map data (public Gesing KML), no closed gatekeeper
//! - client-local queue only โ no proprietary backend API
//! - vocabulary: fleet (work), flat (hold/space), intent (write)
use crateFLAG_SVG;
use crateCyberiaNav;
use crate;
use crate;
use *;
use ;
use HashMap;
use JsCast;
const MAP_JSON: &str = include_str!;
const DOMAIN_POS_KEY: &str = "cyberia.domain_positions.v1";
/// Canonical cybics triad order for domain board grouping.
const TRIAD_ORDER: & = &;
/// Existing Gesing hard-force workers (ops roster) โ 15 people on site.
/// Source: cve/ops/hard force.md crews (repair ยท cube ยท base ยท pruning ยท delivery).
const WORKERS: & = &;
/// Phase-0 machines (hardware fleet).
const MACHINES: & = &;
/// Open ring (no duplicate close point).
/// Planar area in mยฒ via equirectangular projection around centroid (WGS84).
/// Bbox span along axis in meters (axis 0 = lon width, 1 = lat height).
/// SutherlandโHodgman clip against half-plane on lon (axis=0) or lat (axis=1).
/// `keep_le`: keep points with value โค t (west / south side).
/// User-controlled split: axis 0 = vertical cut (E/W), 1 = horizontal (N/S).
/// `ratio` in (0,1) is cut position across the polygon bbox (0 = west/south).
/// Idle borders stay dim so hover/select glow actually reads.
/// Hot = hover or selected โ zone neon / white.
const ACTIONS: & = &;
/// Robots available for purchase (catalog, not yet owned).
const ROBOT_CATALOG: & = &;
/// Project lon/lat into a viewBox-local SVG plane (y flipped).
/// Inverse of `project` โ SVG/world โ lon/lat (for domain drag).
/// Game camera zoom range.
/// Max ~1000 โ ~1.5 m view width on Gesing site โ 10 cm tile โ 60โ70 screen px
/// (enough to layout tiny hex/tiles as a planning game).
const MAP_ZOOM_MIN: f64 = 0.5;
const MAP_ZOOM_MAX: f64 = 1000.0;
/// Keep HUD-sized marks (labels, dots) constant on screen while viewBox zooms.
/// Anchor at (x,y) in SVG/world units.
/// SVG camera: viewBox centered at `center` with size (W/z)ร(H/z).
/// Pure vector zoom โ no CSS scale raster blur.
/// Site envelope size in meters from lon/lat bbox (approx WGS84).
/// Visible world width (meters) at current zoom โ for HUD scale readout.
/// Map client point (relative to wrap) โ world/SVG coords under current camera.
/// Ops map โ fleets rail + flats.
/// Domains board โ same camera, left rail = 21 cybics (draggable shill points).