1use std::cell::RefCell;
18use std::sync::Arc;
19
20use crux_core::{App, Core};
21use leptos::prelude::*;
22use mobiler_core::{
23 Action, BoxAlign, ButtonStyle, CardStyle, ChartBracket, ChartLegendItem, ChartRefLine, ChartRegion,
24 ChartSeries, ChartStyle, ChartTick, Corner, Density, Effect, FieldKind, FontFamily, Icon,
25 ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, ProjectColor,
26 Rgb, Spacing, TextStyle, Theme, Tone, Widget,
27};
28use wasm_bindgen_futures::spawn_local;
29
30const STYLE: &str = include_str!("mobiler.css");
36
37type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
40
41pub trait WebApp:
45 App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
46where
47 Self::Model: Default + Send + Sync,
48{
49}
50impl<T> WebApp for T
51where
52 T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
53 T::Model: Default + Send + Sync,
54{
55}
56
57pub fn run<A: WebApp>()
59where
60 A::Model: Default + Send + Sync,
61{
62 console_error_panic_hook::set_once();
63 inject_default_style();
64 leptos::mount::mount_to_body(shell::<A>);
65}
66
67fn inject_default_style() {
71 let document = leptos::prelude::document();
72 let Some(head) = document.head() else { return };
73 let Ok(style) = document.create_element("style") else { return };
74 let _ = style.set_attribute("data-mobiler", "shell");
75 style.set_text_content(Some(STYLE));
76 let _ = head.insert_before(&style, head.first_child().as_ref());
77}
78
79fn shell<A: WebApp>() -> impl IntoView
80where
81 A::Model: Default + Send + Sync,
82{
83 let core = Arc::new(Core::<A>::new());
84 let (view, set_view) = signal(core.view());
85
86 let send: Dispatch = {
87 let core = core.clone();
88 Arc::new(move |action: Action| {
89 let effects = core.process_event(action);
90 drive(&core, set_view, effects);
91 })
92 };
93
94 let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
97 if !saved.is_empty() {
98 send(Action::Restore { data: saved });
99 }
100 send(Action::Start);
101
102 let send_for_view = send.clone();
103 view! {
104 <div class="app">
105 {move || render(&view.get(), &send_for_view)}
106 </div>
107 }
108}
109
110fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
112where
113 A::Model: Default + Send + Sync,
114{
115 for effect in effects {
116 match effect {
117 Effect::Render(_) => set_view.set(core.view()),
118 Effect::PluginNotify(notify) => perform_notify(¬ify.operation),
119 Effect::Plugin(mut request) => {
120 let core = core.clone();
121 spawn_local(async move {
122 let response = perform(&request.operation).await;
123 if let Ok(next) = core.resolve(&mut request, response) {
124 drive(&core, set_view, next);
125 }
126 });
127 }
128 }
129 }
130}
131
132async fn perform(call: &PluginCall) -> PluginResponse {
135 if call.plugin == "device" {
136 let nav = web_sys::window().map(|w| w.navigator());
137 let output = if call.op == "locale" {
138 nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
140 } else {
141 nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
142 };
143 return PluginResponse { ok: true, output };
144 }
145 if call.plugin == "photo" && call.op == "pick" {
146 return take_image(false).await;
147 }
148 if call.plugin == "camera" && call.op == "capture" {
149 return take_image(true).await;
150 }
151 if call.plugin == "datetime" {
152 return match call.op.as_str() {
153 "date" => take_datetime("date").await,
154 "time" => take_datetime("time").await,
155 other => PluginResponse { ok: false, output: format!("unknown datetime op '{other}'") },
156 };
157 }
158 if call.plugin == "dialog" && call.op == "confirm" {
159 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
160 let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
161 let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
162 let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
163 let ok = web_sys::window()
164 .and_then(|w| w.confirm_with_message(&prompt).ok())
165 .unwrap_or(false);
166 return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
167 }
168 if call.plugin != "http" {
169 return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
170 }
171 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
172 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
173 let body = v.get("body").and_then(serde_json::Value::as_str);
174
175 use gloo_net::http::Request;
176 let builder = match call.op.as_str() {
177 "POST" => Request::post(url),
178 "PATCH" => Request::patch(url),
179 "DELETE" => Request::delete(url),
180 _ => Request::get(url),
181 };
182 let request = match body {
183 Some(b) => builder.header("Content-Type", "application/json").body(b),
184 None => builder.build(),
185 };
186 let request = match request {
187 Ok(r) => r,
188 Err(e) => return PluginResponse { ok: false, output: e.to_string() },
189 };
190 match request.send().await {
191 Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
192 Err(e) => PluginResponse { ok: false, output: e.to_string() },
193 }
194}
195
196async fn take_image(capture: bool) -> PluginResponse {
203 use wasm_bindgen::{closure::Closure, JsCast};
204 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
205 return PluginResponse { ok: false, output: "no document".into() };
206 };
207 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
208 return PluginResponse { ok: false, output: "no input element".into() };
209 };
210 input.set_type("file");
211 input.set_accept("image/*");
212 if capture {
213 let _ = input.set_attribute("capture", "environment");
215 }
216
217 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
218 let tx = std::cell::RefCell::new(Some(tx));
219 let input_for_cb = input.clone();
220 let on_change = Closure::wrap(Box::new(move || {
221 let url = input_for_cb
222 .files()
223 .and_then(|files| files.get(0))
224 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
225 if let Some(tx) = tx.borrow_mut().take() {
226 let _ = tx.send(url);
227 }
228 }) as Box<dyn FnMut()>);
229 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
230 input.click();
231 on_change.forget(); match rx.await {
234 Ok(Some(url)) => PluginResponse { ok: true, output: url },
235 _ => PluginResponse { ok: false, output: "cancelled".into() },
236 }
237}
238
239async fn take_datetime(kind: &str) -> PluginResponse {
244 use wasm_bindgen::{closure::Closure, JsCast};
245 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
246 return PluginResponse { ok: false, output: "no document".into() };
247 };
248 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
249 return PluginResponse { ok: false, output: "no input element".into() };
250 };
251 input.set_type(kind); let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
254 if let Some(body) = doc.body() {
255 let _ = body.append_child(&input);
256 }
257
258 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
259 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
260 let input_for_change = input.clone();
261 let tx_change = tx.clone();
262 let on_change = Closure::wrap(Box::new(move || {
263 let v = input_for_change.value();
264 if let Some(tx) = tx_change.borrow_mut().take() {
265 let _ = tx.send(if v.is_empty() { None } else { Some(v) });
266 }
267 }) as Box<dyn FnMut()>);
268 let tx_cancel = tx.clone();
269 let on_cancel = Closure::wrap(Box::new(move || {
270 if let Some(tx) = tx_cancel.borrow_mut().take() {
271 let _ = tx.send(None);
272 }
273 }) as Box<dyn FnMut()>);
274 let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
275 let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
276 if input.show_picker().is_err() {
277 input.click(); }
279 on_change.forget(); on_cancel.forget();
281
282 let result = rx.await;
283 input.remove();
284 match result {
285 Ok(Some(v)) => PluginResponse { ok: true, output: v },
286 _ => PluginResponse { ok: false, output: "cancelled".into() },
287 }
288}
289
290const STORAGE_KEY: &str = "mobiler.state";
291
292fn local_storage() -> Option<web_sys::Storage> {
294 web_sys::window()?.local_storage().ok().flatten()
295}
296
297fn perform_notify(notify: &PluginNotify) {
301 let win = match web_sys::window() {
302 Some(w) => w,
303 None => return,
304 };
305 match (notify.plugin.as_str(), notify.op.as_str()) {
306 ("storage", "save") => {
308 if let Some(s) = local_storage() {
309 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
310 }
311 }
312 ("clipboard", "copy") => {
314 let _ = win.navigator().clipboard().write_text(¬ify.input);
315 }
316 ("browser", "open") => {
318 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
319 }
320 ("share", _) => {
323 let _ = win.navigator().clipboard().write_text(¬ify.input);
324 }
325 ("toast", _) => show_toast(¬ify.input),
327 ("haptics", style) => {
329 let ms = match style {
330 "light" => 15,
331 "heavy" => 50,
332 _ => 30, };
334 let _ = win.navigator().vibrate_with_duration(ms);
335 }
336 _ => {} }
338}
339
340fn show_toast(text: &str) {
343 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
344 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
345 el.set_class_name("toast");
346 el.set_text_content(Some(text));
347 let _ = body.append_child(&el);
348 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
349}
350
351fn render(widget: &Widget, send: &Dispatch) -> AnyView {
358 match widget {
359 Widget::Text { content, style } => {
361 let (class, content) = (text_class(*style), content.clone());
362 view! { <p class=class>{content}</p> }.into_any()
363 }
364 Widget::Image { source, shape, ratio } => {
365 let (class, source) = (image_class(*shape, *ratio), source.clone());
366 view! { <img class=class src=source /> }.into_any()
367 }
368 Widget::Badge { label, tone } => {
369 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
370 view! { <span class=class>{label}</span> }.into_any()
371 }
372 Widget::ColorDot { color } => {
373 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
374 }
375 Widget::Avatar { source, status } => {
376 let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
377 view! {
378 <span class="avatar">
379 <img class="avatar-img" src=source.clone() />
380 {dot}
381 </span>
382 }
383 .into_any()
384 }
385 Widget::PdfView { url } => {
386 view! { <iframe class="pdfview" src=url.clone() title="PDF"></iframe> }.into_any()
388 }
389 Widget::Rating { value, max, on_rate } => {
390 let value = *value;
391 let stars: Vec<AnyView> = (1..=*max)
392 .map(|i| {
393 let threshold = u32::from(i) * 10;
394 let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
396 match on_rate {
397 Some(tokens) => {
398 let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
399 view! {
400 <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
401 {glyph}
402 </button>
403 }
404 .into_any()
405 }
406 None => view! { <span class="star">{glyph}</span> }.into_any(),
407 }
408 })
409 .collect();
410 view! { <span class="rating">{stars}</span> }.into_any()
411 }
412 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
413 Widget::Progress { value } => match value {
414 Some(v) => {
415 let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
416 view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
417 }
418 None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
419 },
420 Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
421 Widget::Chart { series, labels, style, axis, legend } => {
422 chart_view(series, labels, *style, *axis, *legend)
423 }
424 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
425 region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
426 }
427 Widget::Calendar { year, month, first_weekday, selected, on_day } => {
428 const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
429 "July", "August", "September", "October", "November", "December"];
430 let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
431 let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
432 let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
433 let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
434 let selected = *selected;
435 let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
436 let day = (i + 1) as u8;
437 let token = token.clone();
438 let send = send.clone();
439 let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
440 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
441 }).collect();
442 view! {
443 <div class="calendar">
444 <div class="cal-title">{head_label}</div>
445 <div class="cal-grid">{heads}{blanks}{days}</div>
446 </div>
447 }.into_any()
448 }
449 Widget::SwipeAction { child, actions } => {
450 let acts: Vec<_> = actions.iter().map(|a| {
452 let token = a.on_tap.clone();
453 let send = send.clone();
454 let cls = format!("swipe-act {}", tone_class(a.tone));
455 let label = a.label.clone();
456 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
457 }).collect();
458 view! {
459 <div class="swipe-row">
460 <div class="swipe-content">{render(child, send)}</div>
461 <div class="swipe-actions">{acts}</div>
462 </div>
463 }.into_any()
464 }
465 Widget::Spacer { size } => {
466 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
467 }
468
469 Widget::Row { children } => {
471 let kids = render_all(children, send);
472 view! { <div class="row">{kids}</div> }.into_any()
473 }
474 Widget::Column { children } => {
475 let kids = render_all(children, send);
476 view! { <div class="col">{kids}</div> }.into_any()
477 }
478 Widget::Card { child, style, on_press } => {
479 let class = format!("card {}", card_class(*style));
480 let body = render(child, send);
481 match on_press {
482 Some(token) => {
483 let (send, token) = (send.clone(), token.clone());
484 view! {
485 <button
486 class=format!("{class} card-tappable")
487 on:click=move |_| send(Action::Fired { token: token.clone() })
488 >
489 {body}
490 </button>
491 }
492 .into_any()
493 }
494 None => view! { <div class=class>{body}</div> }.into_any(),
495 }
496 }
497 Widget::Box { children, align, scrim } => {
501 let acls = align_class(*align);
502 if *scrim && children.len() > 1 {
503 let bg = render(&children[0], send);
504 let content = render_all(&children[1..], send);
505 view! {
506 <div class=format!("box box-scrim {acls}")>
507 {bg}
508 <div class="scrim"></div>
509 <div class="box-content">{content}</div>
510 </div>
511 }
512 .into_any()
513 } else {
514 let kids = render_all(children, send);
515 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
516 }
517 }
518 Widget::Grid { children } => {
519 let kids = render_all(children, send);
520 view! { <div class="grid">{kids}</div> }.into_any()
521 }
522 Widget::Scroller { children } => {
523 let kids = render_all(children, send);
524 view! { <div class="scroller">{kids}</div> }.into_any()
525 }
526
527 Widget::Button { label, style, on_press } => {
529 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
530 let class = format!("btn {}", button_class(*style));
531 view! {
532 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
533 {label}
534 </button>
535 }
536 .into_any()
537 }
538 Widget::IconButton { icon, on_press } => {
539 let (send, token) = (send.clone(), on_press.clone());
540 let glyph = icon_glyph(*icon);
541 view! {
542 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
543 {glyph}
544 </button>
545 }
546 .into_any()
547 }
548 Widget::Chip { label, selected, on_press } => {
549 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
550 let class = if *selected { "chip selected" } else { "chip" };
551 view! {
552 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
553 {label}
554 </button>
555 }
556 .into_any()
557 }
558 Widget::TextField { id, placeholder, value, kind, error } => {
559 let (send, id) = (send.clone(), id.clone());
560 let (placeholder, value) = (placeholder.clone(), value.clone());
561 let invalid = error.is_some();
562 let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
563 let (itype, imode): (&str, &str) = match kind {
565 FieldKind::Secure => ("password", ""),
566 FieldKind::Email => ("email", "email"),
567 FieldKind::Number => ("text", "numeric"),
568 FieldKind::Decimal => ("text", "decimal"),
569 FieldKind::Phone => ("tel", "tel"),
570 FieldKind::Url => ("url", "url"),
571 FieldKind::Text | FieldKind::Multiline => ("text", ""),
572 };
573 let field_class = if invalid { "field field-invalid" } else { "field" };
574 let control = if matches!(kind, FieldKind::Multiline) {
575 view! {
576 <textarea
577 class=field_class
578 rows="3"
579 placeholder=placeholder
580 prop:value=value
581 on:input=move |ev| send(Action::Input {
582 id: id.clone(),
583 value: InputValue::Text(event_target_value(&ev)),
584 })
585 ></textarea>
586 }
587 .into_any()
588 } else {
589 view! {
590 <input
591 class=field_class
592 r#type=itype
593 inputmode=imode
594 placeholder=placeholder
595 prop:value=value
596 on:input=move |ev| send(Action::Input {
597 id: id.clone(),
598 value: InputValue::Text(event_target_value(&ev)),
599 })
600 />
601 }
602 .into_any()
603 };
604 view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
605 }
606 Widget::SearchField { id, placeholder, value } => {
607 let (send, id) = (send.clone(), id.clone());
608 let (placeholder, value) = (placeholder.clone(), value.clone());
609 view! {
610 <div class="searchfield">
611 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
612 <input
613 class="search-input"
614 placeholder=placeholder
615 prop:value=value
616 on:input=move |ev| send(Action::Input {
617 id: id.clone(),
618 value: InputValue::Text(event_target_value(&ev)),
619 })
620 />
621 </div>
622 }
623 .into_any()
624 }
625 Widget::Segmented { segments } => {
626 let segs: Vec<AnyView> = segments
627 .iter()
628 .map(|s| {
629 let (send, token) = (send.clone(), s.on_select.clone());
630 let class = if s.selected { "segment selected" } else { "segment" };
631 let label = s.label.clone();
632 view! {
633 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
634 {label}
635 </button>
636 }
637 .into_any()
638 })
639 .collect();
640 view! { <div class="segmented">{segs}</div> }.into_any()
641 }
642 Widget::Toggle { id, label, value } => {
643 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
644 view! {
645 <label class="toggle">
646 {label}
647 <input
648 type="checkbox"
649 role="switch"
650 prop:checked=checked
651 on:change=move |ev| send(Action::Input {
652 id: id.clone(),
653 value: InputValue::Bool(event_target_checked(&ev)),
654 })
655 />
656 </label>
657 }
658 .into_any()
659 }
660 Widget::Checkbox { id, label, value } => {
661 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
662 view! {
663 <label class="check">
664 <input
665 type="checkbox"
666 prop:checked=checked
667 on:change=move |ev| send(Action::Input {
668 id: id.clone(),
669 value: InputValue::Bool(event_target_checked(&ev)),
670 })
671 />
672 {label}
673 </label>
674 }
675 .into_any()
676 }
677 Widget::Slider { id, value, max } => {
678 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
679 view! {
680 <input
681 class="slider"
682 type="range"
683 min="0"
684 max=max
685 prop:value=value
686 on:input=move |ev| send(Action::Input {
687 id: id.clone(),
688 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
689 })
690 />
691 }
692 .into_any()
693 }
694 Widget::Stepper { value, on_decrement, on_increment } => {
695 let send_dec = send.clone();
696 let send_inc = send.clone();
697 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
698 view! {
699 <div class="stepper">
700 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
701 <span class="stepper-value">{*value}</span>
702 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
703 </div>
704 }
705 .into_any()
706 }
707
708 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
710 let back_btn = back.clone().map(|token| {
711 let send = send.clone();
712 view! {
713 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
714 "‹"
715 </button>
716 }
717 });
718 let tabbar = (!tabs.is_empty()).then(|| {
719 let tabs: Vec<AnyView> = tabs
720 .iter()
721 .map(|tab| {
722 let (send, token) = (send.clone(), tab.on_select.clone());
723 let class = if tab.selected { "tab selected" } else { "tab" };
724 let label = tab.label.clone();
725 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
727 view! {
728 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
729 {icon}
730 <span class="tab-label">{label}</span>
731 </button>
732 }
733 .into_any()
734 })
735 .collect();
736 view! { <div class="tabbar">{tabs}</div> }
737 });
738 let fab_btn = fab.clone().map(|f| {
740 let (send, token) = (send.clone(), f.on_press.clone());
741 view! {
742 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
743 {icon_glyph(f.icon)}
744 </button>
745 }
746 });
747 let sheet_overlay = sheet.as_ref().map(|s| {
749 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
750 let (title, child) = (s.title.clone(), render(&s.child, send));
751 view! {
752 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
753 <div class="sheet">
754 <div class="sheet-handle"></div>
755 <div class="sheet-title">{title}</div>
756 {child}
757 </div>
758 }
759 });
760 let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
763 let refresh_btn = on_refresh.clone().map(|token| {
766 let send = send.clone();
767 view! {
768 <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
769 }
770 });
771 let refresh_bar = refreshing.then(|| {
772 view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
773 });
774 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
775 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
778 let (title, body) = (title.clone(), render(body, send));
779 view! {
780 <div class=class style=theme_style>
781 <div class="topbar">
782 {back_btn}
783 <span class="title">{title}</span>
784 {refresh_btn}
785 </div>
786 <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
787 {fab_btn}
788 {tabbar}
789 {sheet_overlay}
790 </div>
791 }
792 .into_any()
793 }
794 }
795}
796
797fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
799 children.iter().map(|c| render(c, send)).collect()
800}
801
802thread_local! {
803 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
808}
809
810fn theme_css(t: &Theme) -> String {
815 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
816 let radius = match t.corner {
817 Corner::None => "0px",
818 Corner::Small => "8px",
819 Corner::Medium => "14px",
820 Corner::Large => "22px",
821 };
822 let (gap, pad) = match t.density {
823 Density::Compact => ("8px", "10px"),
824 Density::Comfortable => ("12px", "14px"),
825 };
826 let font = match t.font {
827 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
828 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
829 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
830 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
831 };
832 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
834 format!(
835 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
836 --accent2:rgb({ar},{ag},{ab});\
837 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
838 --gap:{gap};--pad:{pad};--font:{font};"
839 )
840}
841
842fn nav_class(route: &str, depth: u32) -> &'static str {
849 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
850 if route == prev_route {
851 return "";
852 }
853 let dir = if depth > *prev_depth {
854 ["nav-push-a", "nav-push-b"]
855 } else if depth < *prev_depth {
856 ["nav-pop-a", "nav-pop-b"]
857 } else {
858 ["nav-fade-a", "nav-fade-b"]
859 };
860 *toggle = !*toggle;
861 *prev_route = route.to_string();
862 *prev_depth = depth;
863 dir[usize::from(*toggle)]
864 })
865}
866
867fn text_class(s: TextStyle) -> &'static str {
870 match s {
871 TextStyle::Title => "t-title",
872 TextStyle::Subtitle => "t-subtitle",
873 TextStyle::Caption => "t-caption",
874 TextStyle::Emphasis => "t-emphasis",
875 TextStyle::Body => "t-body",
876 }
877}
878
879fn button_class(s: ButtonStyle) -> &'static str {
880 match s {
881 ButtonStyle::Filled => "btn-filled",
882 ButtonStyle::Outlined => "btn-outlined",
883 ButtonStyle::Text => "btn-text",
884 }
885}
886
887fn card_class(s: CardStyle) -> &'static str {
888 match s {
889 CardStyle::Elevated => "card-elevated",
890 CardStyle::Outlined => "card-outlined",
891 CardStyle::Filled => "card-filled",
892 CardStyle::Brand => "card-brand",
893 }
894}
895
896fn tone_class(t: Tone) -> &'static str {
897 match t {
898 Tone::Neutral => "tone-neutral",
899 Tone::Success => "tone-success",
900 Tone::Warning => "tone-warning",
901 Tone::Danger => "tone-danger",
902 Tone::Info => "tone-info",
903 }
904}
905
906fn spacer_class(s: Spacing) -> &'static str {
907 match s {
908 Spacing::Xs => "sp-xs",
909 Spacing::Sm => "sp-sm",
910 Spacing::Md => "sp-md",
911 Spacing::Lg => "sp-lg",
912 Spacing::Xl => "sp-xl",
913 }
914}
915
916fn icon_glyph(i: Icon) -> &'static str {
917 match i {
918 Icon::Delete => "🗑",
919 Icon::Add => "+",
920 Icon::Edit => "✏️",
921 Icon::Close => "✕",
922 Icon::Settings => "⚙",
923 Icon::Check => "✓",
924 Icon::Star => "★",
925 Icon::Info => "ℹ",
926 Icon::Home => "⌂",
927 Icon::Search => "🔍",
928 Icon::Menu => "☰",
929 Icon::Filter => "⚟",
930 Icon::Back => "‹",
931 Icon::Forward => "›",
932 Icon::Down => "⌄",
933 Icon::Bell => "🔔",
934 Icon::Cart => "🛒",
935 Icon::Share => "↗",
936 Icon::Heart => "♡",
937 Icon::HeartFilled => "♥",
938 Icon::Person => "👤",
939 Icon::People => "👥",
940 Icon::Phone => "📞",
941 Icon::Mail => "✉",
942 Icon::Calendar => "📅",
943 Icon::Clock => "🕑",
944 Icon::MapPin => "📍",
945 Icon::Camera => "📷",
946 Icon::Photo => "🖼",
947 Icon::Play => "▶",
948 Icon::Scissors => "✂",
949 }
950}
951
952fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
953 let shape = match shape {
954 ImageShape::Square => "img-square",
955 ImageShape::Rounded => "img-rounded",
956 ImageShape::Circle => "img-circle",
957 };
958 let ratio = match ratio {
959 ImageRatio::Wide => "ratio-wide",
960 ImageRatio::Square => "ratio-square",
961 ImageRatio::Tall => "ratio-tall",
962 };
963 format!("img {shape} {ratio}")
964}
965
966fn dot_class(c: ProjectColor) -> &'static str {
967 match c {
968 ProjectColor::Indigo => "dot-indigo",
969 ProjectColor::Teal => "dot-teal",
970 ProjectColor::Coral => "dot-coral",
971 ProjectColor::Amber => "dot-amber",
972 ProjectColor::Lime => "dot-lime",
973 ProjectColor::Pink => "dot-pink",
974 }
975}
976
977fn align_class(a: BoxAlign) -> &'static str {
978 match a {
979 BoxAlign::TopStart => "align-top-start",
980 BoxAlign::TopEnd => "align-top-end",
981 BoxAlign::Center => "align-center",
982 BoxAlign::BottomStart => "align-bottom-start",
983 BoxAlign::BottomCenter => "align-bottom-center",
984 BoxAlign::BottomEnd => "align-bottom-end",
985 }
986}
987
988const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
992
993fn hex(c: Rgb) -> String {
994 format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
995}
996
997fn chart_color(i: usize, s: &ChartSeries) -> String {
999 match s.color {
1000 Some(c) => hex(c),
1001 None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
1002 None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
1003 }
1004}
1005
1006fn chart_mag(s: &ChartSeries) -> f32 {
1008 s.values.iter().copied().sum()
1009}
1010
1011fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
1013 (cx + r * ang.sin(), cy - r * ang.cos())
1014}
1015
1016fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1018 let (x0, y0) = polar(cx, cy, r, a0);
1019 let (x1, y1) = polar(cx, cy, r, a1);
1020 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1021 format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
1022}
1023
1024fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1026 let (x0, y0) = polar(cx, cy, r, a0);
1027 let (x1, y1) = polar(cx, cy, r, a1);
1028 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1029 format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
1030}
1031
1032fn fmt_tick(v: f32) -> String {
1033 if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
1034}
1035
1036fn is_cartesian(style: ChartStyle) -> bool {
1037 matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
1038}
1039
1040fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
1042 match style {
1043 ChartStyle::StackedBar => (0..nslots)
1044 .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
1045 .fold(0.0, f32::max)
1046 .max(1e-6),
1047 ChartStyle::StackedBar100 => 1.0,
1048 _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
1049 }
1050}
1051
1052fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
1053 let mut nodes: Vec<AnyView> = Vec::new();
1055 if axis {
1056 for k in 0..=4 {
1057 let y = 2.0 + k as f32 * (46.0 / 4.0);
1058 nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
1059 }
1060 }
1061 match style {
1062 ChartStyle::Line => {
1063 for (i, s) in series.iter().enumerate() {
1064 let n = s.values.len().max(1);
1065 let pts = s.values.iter().enumerate().map(|(j, v)| {
1066 let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
1067 let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
1068 format!("{x:.2},{y:.2}")
1069 }).collect::<Vec<_>>().join(" ");
1070 let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
1071 nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
1072 }
1073 }
1074 ChartStyle::Bar => {
1075 let sw = 100.0 / nslots as f32;
1076 let ns = series.len().max(1);
1077 for (i, s) in series.iter().enumerate() {
1078 let st = format!("fill:{}", chart_color(i, s));
1079 for (j, v) in s.values.iter().enumerate() {
1080 let h = (v / max).clamp(0.0, 1.0) * 46.0;
1081 let bw = sw * 0.8 / ns as f32;
1082 let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
1083 let y = 48.0 - h;
1084 nodes.push(view! { <rect x=format!("{x:.2}") y=format!("{y:.2}") width=format!("{bw:.2}") height=format!("{h:.2}") style=st.clone()></rect> }.into_any());
1085 }
1086 }
1087 }
1088 ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
1089 let sw = 100.0 / nslots as f32;
1090 for j in 0..nslots {
1091 let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
1092 let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
1093 let mut acc = 0.0_f32;
1094 for (i, s) in series.iter().enumerate() {
1095 let v = *s.values.get(j).unwrap_or(&0.0);
1096 let h = (v / denom).clamp(0.0, 1.0) * 46.0;
1097 let x = j as f32 * sw + sw * 0.15;
1098 let bw = sw * 0.7;
1099 let y = 48.0 - acc - h;
1100 let st = format!("fill:{}", chart_color(i, s));
1101 nodes.push(view! { <rect x=format!("{x:.2}") y=format!("{y:.2}") width=format!("{bw:.2}") height=format!("{h:.2}") style=st></rect> }.into_any());
1102 acc += h;
1103 }
1104 }
1105 }
1106 _ => {}
1107 }
1108 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
1109}
1110
1111fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
1112 use std::f32::consts::PI;
1113 let mut nodes: Vec<AnyView> = Vec::new();
1114 match style {
1115 ChartStyle::Pie | ChartStyle::Donut => {
1116 let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
1117 let mut a = 0.0_f32;
1118 for (i, s) in series.iter().enumerate() {
1119 let frac = chart_mag(s) / total;
1120 let st = format!("fill:{}", chart_color(i, s));
1121 if frac >= 0.999 {
1122 nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
1123 } else if frac > 0.0 {
1124 let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
1125 nodes.push(view! { <path d=d style=st></path> }.into_any());
1126 }
1127 a += frac * 2.0 * PI;
1128 }
1129 if matches!(style, ChartStyle::Donut) {
1130 nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
1131 }
1132 }
1133 ChartStyle::Rings => {
1134 let n = series.len().max(1);
1135 for (i, s) in series.iter().enumerate() {
1136 let r = 45.0 - i as f32 * (34.0 / n as f32);
1137 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1138 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1139 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style="fill:none;stroke:var(--border, #e6e6e6);stroke-width:6"></circle> }.into_any());
1140 let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
1141 if prog >= 0.999 {
1142 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
1143 } else if prog > 0.0 {
1144 let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
1145 nodes.push(view! { <path d=d style=st></path> }.into_any());
1146 }
1147 }
1148 }
1149 ChartStyle::Gauge => {
1150 let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
1151 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1152 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1153 let a0 = -0.75 * PI; let a1 = 0.75 * PI;
1155 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a1) style="fill:none;stroke:var(--border, #e6e6e6);stroke-width:8;stroke-linecap:round"></path> }.into_any());
1156 if prog > 0.0 {
1157 let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
1158 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
1159 }
1160 let pct = format!("{}%", (prog * 100.0).round() as i64);
1161 nodes.push(view! { <text x="50" y="56" style="fill:var(--fg, #222);font-size:20px;font-weight:700;text-anchor:middle">{pct}</text> }.into_any());
1162 }
1163 _ => {}
1164 }
1165 view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
1166}
1167
1168fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
1169 let cartesian = is_cartesian(style);
1170 let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
1171 let max = cartesian_max(series, style, nslots);
1172
1173 let plot = if cartesian {
1174 let svg = cartesian_svg(series, style, axis, max, nslots);
1175 let yaxis = if axis {
1176 let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
1177 .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
1178 .collect();
1179 Some(view! { <div class="chart-yaxis">{ticks}</div> })
1180 } else {
1181 None
1182 };
1183 view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
1184 } else {
1185 circular_svg(series, style).into_any()
1186 };
1187
1188 let label_row = if cartesian && !labels.is_empty() {
1189 let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
1190 Some(view! { <div class="chart-labels">{items}</div> })
1191 } else {
1192 None
1193 };
1194
1195 let legend_row = if legend {
1196 let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
1197 let sw = format!("background:{}", chart_color(i, s));
1198 let name = s.name.clone();
1199 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1200 }).collect();
1201 Some(view! { <div class="chart-legend">{items}</div> })
1202 } else {
1203 None
1204 };
1205
1206 view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
1207}
1208
1209const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
1213 [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
1214
1215fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
1217 match r.color {
1218 Some(c) => (c.r, c.g, c.b),
1219 None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
1220 }
1221}
1222
1223fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
1225 let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
1226 if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
1227}
1228
1229fn region_color(i: usize, r: &ChartRegion) -> String {
1230 let (r8, g8, b8) = region_rgb(i, r);
1231 format!("#{r8:02x}{g8:02x}{b8:02x}")
1232}
1233
1234fn region_chart_view(
1238 regions: &[ChartRegion],
1239 ticks: &[ChartTick],
1240 x_max: f32,
1241 y_max: f32,
1242 ref_lines: &[ChartRefLine],
1243 bracket: &Option<ChartBracket>,
1244 legend: &[ChartLegendItem],
1245) -> AnyView {
1246 let xm = x_max.max(1e-6);
1247 let ym = y_max.max(1e-6);
1248
1249 let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
1250 let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
1251 let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
1252 let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
1253 let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
1254 let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
1255 let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
1256 let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
1257 let label = r.label.clone();
1258 view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
1259 }).collect();
1260
1261 let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
1264 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1265 let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
1266 view! { <div class=cls style=style></div> }
1267 }).collect();
1268 let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
1269 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1270 let label = rl.label.clone();
1271 view! { <div class="rchart-chip" style=style>{label}</div> }
1272 }).collect();
1273
1274 let bracket_div = bracket.as_ref().map(|b| {
1275 let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
1276 let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
1277 let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
1278 let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
1279 view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
1280 });
1281
1282 let yticks: Vec<_> = (0..=4).rev().map(|k| {
1283 let v = ym * k as f32 / 4.0;
1284 view! { <span class="chart-tick">{fmt_tick(v)}</span> }
1285 }).collect();
1286
1287 let xticks: Vec<_> = ticks.iter().map(|t| {
1288 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1289 let label = t.label.clone();
1290 view! { <span class="rchart-xtick" style=style>{label}</span> }
1291 }).collect();
1292
1293 let ytick_marks: Vec<_> = (0..=4).map(|k| {
1296 let style = format!("bottom:{:.3}%", k as f32 * 25.0);
1297 view! { <div class="rchart-ytick" style=style></div> }
1298 }).collect();
1299 let xtick_marks: Vec<_> = ticks.iter().map(|t| {
1300 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1301 view! { <div class="rchart-xtickmark" style=style></div> }
1302 }).collect();
1303
1304 let legend_row = if legend.is_empty() {
1305 None
1306 } else {
1307 let items: Vec<_> = legend.iter().map(|l| {
1308 let sw = format!("background:{}", hex(l.color));
1309 let name = l.label.clone();
1310 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1311 }).collect();
1312 Some(view! { <div class="chart-legend">{items}</div> })
1313 };
1314
1315 view! {
1316 <div class="rchart">
1317 <div class="rchart-row">
1318 <div class="rchart-yaxis">{yticks}</div>
1319 <div class="rchart-plotwrap">
1320 <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
1321 {chip_divs}{bracket_div}
1322 </div>
1323 </div>
1324 <div class="rchart-xaxis">{xticks}</div>
1325 {legend_row}
1326 </div>
1327 }.into_any()
1328}