1use std::cell::RefCell;
18use std::collections::HashMap;
19use std::rc::Rc;
20use std::sync::Arc;
21
22use crux_core::{App, Core, Request};
23use leptos::prelude::*;
24use mobiler_core::{
25 A11yRole, Action, BoxAlign, ButtonStyle, CardStyle, ChartBracket, ChartLegendItem, ChartRefLine, ChartRegion,
26 ChartSeries, ChartStyle, ChartTick, Corner, Density, Effect, FieldKind, FontFamily, Icon,
27 ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, PluginStreamCall, ProjectColor,
28 Rgb, Spacing, TextStyle, Theme, Tone, Widget,
29};
30use wasm_bindgen_futures::spawn_local;
31
32const STYLE: &str = include_str!("mobiler.css");
38
39type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
42
43pub trait WebApp:
47 App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
48where
49 Self::Model: Default + Send + Sync,
50{
51}
52impl<T> WebApp for T
53where
54 T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
55 T::Model: Default + Send + Sync,
56{
57}
58
59pub fn run<A: WebApp>()
61where
62 A::Model: Default + Send + Sync,
63{
64 console_error_panic_hook::set_once();
65 inject_default_style();
66 inject_hls_support();
67 leptos::mount::mount_to_body(shell::<A>);
68}
69
70fn inject_hls_support() {
80 const BOOTSTRAP: &str = r#"(function(){
81 function ensureHls(cb){
82 if(window.Hls){return cb();}
83 if(window.__mobilerHlsLoading){(window.__mobilerHlsCbs=window.__mobilerHlsCbs||[]).push(cb);return;}
84 window.__mobilerHlsLoading=true;window.__mobilerHlsCbs=[cb];
85 var s=document.createElement('script');
86 s.src='https://cdn.jsdelivr.net/npm/hls.js@1';
87 var flush=function(){var cbs=window.__mobilerHlsCbs||[];window.__mobilerHlsCbs=[];cbs.forEach(function(f){f();});};
88 s.onload=flush;s.onerror=flush;
89 document.head.appendChild(s);
90 }
91 function attach(v){
92 if(v.__mobilerHlsDone){return;}v.__mobilerHlsDone=true;
93 var url=v.getAttribute('data-hls-src');if(!url){return;}
94 if(v.canPlayType('application/vnd.apple.mpegurl')){v.src=url;return;}
95 ensureHls(function(){
96 if(window.Hls&&window.Hls.isSupported()){var h=new window.Hls();h.loadSource(url);h.attachMedia(v);v.__mobilerHls=h;}
97 else{v.src=url;}
98 });
99 }
100 function scan(root){if(root&&root.querySelectorAll){root.querySelectorAll('video[data-hls-src]').forEach(attach);}}
101 new MutationObserver(function(muts){muts.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches('video[data-hls-src]')){attach(n);}scan(n);}});});}).observe(document.documentElement,{childList:true,subtree:true});
102 scan(document);
103})();"#;
104 let document = leptos::prelude::document();
105 let Some(head) = document.head() else { return };
106 let Ok(script) = document.create_element("script") else { return };
107 script.set_text_content(Some(BOOTSTRAP));
108 let _ = head.append_child(&script);
109}
110
111fn inject_default_style() {
115 let document = leptos::prelude::document();
116 let Some(head) = document.head() else { return };
117 let Ok(style) = document.create_element("style") else { return };
118 let _ = style.set_attribute("data-mobiler", "shell");
119 style.set_text_content(Some(STYLE));
120 let _ = head.insert_before(&style, head.first_child().as_ref());
121}
122
123fn shell<A: WebApp>() -> impl IntoView
124where
125 A::Model: Default + Send + Sync,
126{
127 let core = Arc::new(Core::<A>::new());
128 let (view, set_view) = signal(core.view());
129
130 let send: Dispatch = {
131 let core = core.clone();
132 Arc::new(move |action: Action| {
133 let effects = core.process_event(action);
134 drive(&core, set_view, effects);
135 })
136 };
137
138 let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
141 if !saved.is_empty() {
142 send(Action::Restore { data: saved });
143 }
144 send(Action::Start);
145
146 let send_for_view = send.clone();
147 view! {
148 <div class="app">
149 {move || render(&view.get(), &send_for_view)}
150 </div>
151 }
152}
153
154fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
156where
157 A::Model: Default + Send + Sync,
158{
159 for effect in effects {
160 match effect {
161 Effect::Render(_) => set_view.set(core.view()),
162 Effect::PluginNotify(notify) => perform_notify(¬ify.operation),
163 Effect::Plugin(mut request) => {
164 let core = core.clone();
165 spawn_local(async move {
166 let response = perform(&request.operation).await;
167 if let Ok(next) = core.resolve(&mut request, response) {
168 drive(&core, set_view, next);
169 }
170 });
171 }
172 Effect::PluginStream(request) => start_stream(core, set_view, request),
175 }
176 }
177}
178
179fn start_stream<A: WebApp>(
188 core: &Arc<Core<A>>,
189 set_view: WriteSignal<Widget>,
190 request: Request<PluginStreamCall>,
191) where
192 A::Model: Default + Send + Sync,
193{
194 use wasm_bindgen::{closure::Closure, JsCast};
195
196 let call = request.operation.clone();
197
198 let request = Rc::new(RefCell::new(request));
201 let core = core.clone();
202 let emit = move |resp: PluginResponse| {
203 if let Ok(next) = core.resolve(&mut *request.borrow_mut(), resp) {
204 drive(&core, set_view, next);
205 }
206 };
207
208 let handle = match (call.plugin.as_str(), call.op.as_str()) {
209 ("ticker", "start") => {
212 let ms: u32 = call.input.parse().unwrap_or(1000);
213 let count = std::cell::Cell::new(0u32);
214 let interval = gloo_timers::callback::Interval::new(ms, move || {
215 count.set(count.get() + 1);
216 emit(PluginResponse { ok: true, output: count.get().to_string() });
217 });
218 StreamHandle::Ticker { _interval: interval }
219 }
220 ("websocket", "stream") => {
221 let Ok(ws) = web_sys::WebSocket::new(&call.input) else { return };
222 let onmessage = {
223 let emit = emit.clone();
224 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
225 emit(PluginResponse { ok: true, output: e.data().as_string().unwrap_or_default() });
226 })
227 };
228 let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
229 emit(PluginResponse { ok: false, output: "closed".into() });
230 });
231 ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
232 ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
233 StreamHandle::Ws(WsStream { ws, _onmessage: onmessage, _onclose: onclose })
234 }
235 ("system", "events") => {
239 let win = web_sys::window().expect("window");
240 let doc = win.document().expect("document");
241 if let Ok(href) = win.location().href() {
243 emit(PluginResponse { ok: true, output: system_deeplink(&href) });
244 }
245 emit(PluginResponse { ok: true, output: system_lifecycle(&doc) });
246 let onpop = {
247 let (emit, win) = (emit.clone(), win.clone());
248 Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
249 if let Ok(href) = win.location().href() {
250 emit(PluginResponse { ok: true, output: system_deeplink(&href) });
251 }
252 })
253 };
254 let onvis = {
255 let (emit, doc) = (emit.clone(), doc.clone());
256 Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
257 emit(PluginResponse { ok: true, output: system_lifecycle(&doc) });
258 })
259 };
260 let _ = win.add_event_listener_with_callback("popstate", onpop.as_ref().unchecked_ref());
261 let _ = doc.add_event_listener_with_callback("visibilitychange", onvis.as_ref().unchecked_ref());
262 StreamHandle::System(SystemStream { win, doc, _onpop: onpop, _onvis: onvis })
263 }
264 _ => return, };
266
267 STREAMS.with(|m| {
268 m.borrow_mut().insert(call.key.clone(), handle);
269 });
270}
271
272fn system_deeplink(url: &str) -> String {
274 format!("{{\"type\":\"deeplink\",\"url\":{}}}", serde_json::to_string(url).unwrap_or_else(|_| "\"\"".into()))
275}
276fn system_lifecycle(doc: &web_sys::Document) -> String {
278 let state = if doc.visibility_state() == web_sys::VisibilityState::Visible { "active" } else { "background" };
279 format!("{{\"type\":\"lifecycle\",\"state\":\"{state}\"}}")
280}
281
282enum StreamHandle {
286 Ticker { _interval: gloo_timers::callback::Interval },
288 Ws(WsStream),
289 #[allow(dead_code)]
292 System(SystemStream),
293}
294
295struct SystemStream {
297 win: web_sys::Window,
298 doc: web_sys::Document,
299 _onpop: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
300 _onvis: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
301}
302impl Drop for SystemStream {
303 fn drop(&mut self) {
304 use wasm_bindgen::JsCast;
305 let _ = self.win.remove_event_listener_with_callback("popstate", self._onpop.as_ref().unchecked_ref());
306 let _ = self.doc.remove_event_listener_with_callback("visibilitychange", self._onvis.as_ref().unchecked_ref());
307 }
308}
309
310struct WsStream {
312 ws: web_sys::WebSocket,
313 _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
314 _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
315}
316
317async fn perform(call: &PluginCall) -> PluginResponse {
320 if call.plugin == "device" {
321 let nav = web_sys::window().map(|w| w.navigator());
322 let output = if call.op == "locale" {
323 nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
325 } else {
326 nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
327 };
328 return PluginResponse { ok: true, output };
329 }
330 if call.plugin == "photo" && call.op == "pick" {
331 return take_image(false).await;
332 }
333 if call.plugin == "camera" && call.op == "capture" {
334 return take_image(true).await;
335 }
336 if call.plugin == "datetime" {
337 return match call.op.as_str() {
338 "date" => take_datetime("date").await,
339 "time" => take_datetime("time").await,
340 other => PluginResponse { ok: false, output: format!("unknown datetime op '{other}'") },
341 };
342 }
343 if call.plugin == "dialog" && call.op == "confirm" {
344 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
345 let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
346 let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
347 let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
348 let ok = web_sys::window()
349 .and_then(|w| w.confirm_with_message(&prompt).ok())
350 .unwrap_or(false);
351 return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
352 }
353 if call.plugin != "http" {
354 return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
355 }
356 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
357 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
358 let body = v.get("body").and_then(serde_json::Value::as_str);
359
360 use gloo_net::http::Request;
361 let builder = match call.op.as_str() {
362 "POST" => Request::post(url),
363 "PATCH" => Request::patch(url),
364 "DELETE" => Request::delete(url),
365 _ => Request::get(url),
366 };
367 let request = match body {
368 Some(b) => builder.header("Content-Type", "application/json").body(b),
369 None => builder.build(),
370 };
371 let request = match request {
372 Ok(r) => r,
373 Err(e) => return PluginResponse { ok: false, output: e.to_string() },
374 };
375 match request.send().await {
376 Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
377 Err(e) => PluginResponse { ok: false, output: e.to_string() },
378 }
379}
380
381async fn take_image(capture: bool) -> PluginResponse {
388 use wasm_bindgen::{closure::Closure, JsCast};
389 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
390 return PluginResponse { ok: false, output: "no document".into() };
391 };
392 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
393 return PluginResponse { ok: false, output: "no input element".into() };
394 };
395 input.set_type("file");
396 input.set_accept("image/*");
397 if capture {
398 let _ = input.set_attribute("capture", "environment");
400 }
401
402 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
403 let tx = std::cell::RefCell::new(Some(tx));
404 let input_for_cb = input.clone();
405 let on_change = Closure::wrap(Box::new(move || {
406 let url = input_for_cb
407 .files()
408 .and_then(|files| files.get(0))
409 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
410 if let Some(tx) = tx.borrow_mut().take() {
411 let _ = tx.send(url);
412 }
413 }) as Box<dyn FnMut()>);
414 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
415 input.click();
416 on_change.forget(); match rx.await {
419 Ok(Some(url)) => PluginResponse { ok: true, output: url },
420 _ => PluginResponse { ok: false, output: "cancelled".into() },
421 }
422}
423
424async fn take_datetime(kind: &str) -> PluginResponse {
429 use wasm_bindgen::{closure::Closure, JsCast};
430 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
431 return PluginResponse { ok: false, output: "no document".into() };
432 };
433 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
434 return PluginResponse { ok: false, output: "no input element".into() };
435 };
436 input.set_type(kind); let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
439 if let Some(body) = doc.body() {
440 let _ = body.append_child(&input);
441 }
442
443 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
444 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
445 let input_for_change = input.clone();
446 let tx_change = tx.clone();
447 let on_change = Closure::wrap(Box::new(move || {
448 let v = input_for_change.value();
449 if let Some(tx) = tx_change.borrow_mut().take() {
450 let _ = tx.send(if v.is_empty() { None } else { Some(v) });
451 }
452 }) as Box<dyn FnMut()>);
453 let tx_cancel = tx.clone();
454 let on_cancel = Closure::wrap(Box::new(move || {
455 if let Some(tx) = tx_cancel.borrow_mut().take() {
456 let _ = tx.send(None);
457 }
458 }) as Box<dyn FnMut()>);
459 let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
460 let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
461 if input.show_picker().is_err() {
462 input.click(); }
464 on_change.forget(); on_cancel.forget();
466
467 let result = rx.await;
468 input.remove();
469 match result {
470 Ok(Some(v)) => PluginResponse { ok: true, output: v },
471 _ => PluginResponse { ok: false, output: "cancelled".into() },
472 }
473}
474
475const STORAGE_KEY: &str = "mobiler.state";
476
477fn local_storage() -> Option<web_sys::Storage> {
479 web_sys::window()?.local_storage().ok().flatten()
480}
481
482fn perform_notify(notify: &PluginNotify) {
486 let win = match web_sys::window() {
487 Some(w) => w,
488 None => return,
489 };
490 match (notify.plugin.as_str(), notify.op.as_str()) {
491 ("storage", "save") => {
493 if let Some(s) = local_storage() {
494 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
495 }
496 }
497 ("clipboard", "copy") => {
499 let _ = win.navigator().clipboard().write_text(¬ify.input);
500 }
501 ("browser", "open") => {
503 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
504 }
505 ("share", _) => {
508 let _ = win.navigator().clipboard().write_text(¬ify.input);
509 }
510 ("stream", "unsubscribe") => {
514 if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(¬ify.input)) {
517 let _ = ws.ws.close();
518 }
519 }
520 ("toast", _) => show_toast(¬ify.input),
522 ("haptics", style) => {
524 let ms = match style {
525 "light" => 15,
526 "heavy" => 50,
527 _ => 30, };
529 let _ = win.navigator().vibrate_with_duration(ms);
530 }
531 _ => {} }
533}
534
535fn show_toast(text: &str) {
538 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
539 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
540 el.set_class_name("toast");
541 el.set_text_content(Some(text));
542 let _ = body.append_child(&el);
543 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
544}
545
546fn render(widget: &Widget, send: &Dispatch) -> AnyView {
553 match widget {
554 Widget::Text { content, style } => {
556 let (class, content) = (text_class(*style), content.clone());
557 view! { <p class=class>{content}</p> }.into_any()
558 }
559 Widget::Image { source, shape, ratio } => {
560 let (class, source) = (image_class(*shape, *ratio), source.clone());
561 view! { <img class=class src=source /> }.into_any()
562 }
563 Widget::Badge { label, tone } => {
564 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
565 view! { <span class=class>{label}</span> }.into_any()
566 }
567 Widget::ColorDot { color } => {
568 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
569 }
570 Widget::Avatar { source, status } => {
571 let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
572 view! {
573 <span class="avatar">
574 <img class="avatar-img" src=source.clone() />
575 {dot}
576 </span>
577 }
578 .into_any()
579 }
580 Widget::PdfView { url } => {
581 view! { <iframe class="pdfview" src=url.clone() title="PDF"></iframe> }.into_any()
583 }
584 Widget::WebView { url } => {
585 view! {
588 <iframe
589 class="webview"
590 src=url.clone()
591 title="Web"
592 allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
593 allowfullscreen=true
594 ></iframe>
595 }.into_any()
596 }
597 Widget::Video { url, playing, controls, looping, muted, on_ended, poster, start_at_ms, captions, rate, volume, urls, start_index, .. } => {
598 use wasm_bindgen::JsCast;
606 let (send, ended) = (send.clone(), on_ended.clone());
607 let autoplay = *playing && *muted;
608 let playlist = urls.clone();
609 let start_index = (*start_index).max(0) as usize;
610 let effective = if playlist.is_empty() { url.clone() }
611 else { playlist.get(start_index).cloned().unwrap_or_else(|| url.clone()) };
612 let is_hls = effective.to_ascii_lowercase().ends_with(".m3u8");
613 let src = (!is_hls).then(|| effective.clone());
614 let hls_src = is_hls.then(|| effective.clone());
615 let poster_attr = poster.clone();
616 let start_at = *start_at_ms;
617 let rate = *rate as f64;
618 let volume = (*volume as f64).clamp(0.0, 1.0);
619 let tracks: Vec<_> = captions.iter().map(|c| view! {
620 <track kind="subtitles" src=c.url.clone() srclang=c.language.clone() label=c.label.clone() default=c.default_on />
621 }).collect();
622 let next_idx = std::rc::Rc::new(std::cell::Cell::new(start_index));
623 view! {
624 <video
625 class="video"
626 src=src
627 data-hls-src=hls_src
628 poster=poster_attr
629 controls=*controls
630 autoplay=autoplay
631 prop:loop=*looping
632 prop:playbackRate=rate
633 prop:volume=volume
634 muted=*muted
635 playsinline=true
636 on:loadedmetadata=move |ev| {
637 if start_at >= 0 {
638 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
639 v.set_current_time(start_at as f64 / 1000.0);
640 }
641 }
642 }
643 on:ended=move |ev| {
644 let nxt = next_idx.get() + 1;
645 if !playlist.is_empty() && nxt < playlist.len() {
646 next_idx.set(nxt);
647 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
648 v.set_src(&playlist[nxt]);
649 let _ = v.play();
650 }
651 } else if let Some(t) = ended.clone() {
652 send(Action::Fired { token: t });
653 }
654 }
655 >{tracks}</video>
656 }.into_any()
657 }
658 Widget::Rating { value, max, on_rate } => {
659 let value = *value;
660 let stars: Vec<AnyView> = (1..=*max)
661 .map(|i| {
662 let threshold = u32::from(i) * 10;
663 let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
665 match on_rate {
666 Some(tokens) => {
667 let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
668 view! {
669 <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
670 {glyph}
671 </button>
672 }
673 .into_any()
674 }
675 None => view! { <span class="star">{glyph}</span> }.into_any(),
676 }
677 })
678 .collect();
679 view! { <span class="rating">{stars}</span> }.into_any()
680 }
681 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
682 Widget::Progress { value } => match value {
683 Some(v) => {
684 let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
685 view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
686 }
687 None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
688 },
689 Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
690 Widget::Chart { series, labels, style, axis, legend } => {
691 chart_view(series, labels, *style, *axis, *legend)
692 }
693 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
694 region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
695 }
696 Widget::Calendar { year, month, first_weekday, selected, on_day } => {
697 const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
698 "July", "August", "September", "October", "November", "December"];
699 let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
700 let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
701 let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
702 let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
703 let selected = *selected;
704 let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
705 let day = (i + 1) as u8;
706 let token = token.clone();
707 let send = send.clone();
708 let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
709 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
710 }).collect();
711 view! {
712 <div class="calendar">
713 <div class="cal-title">{head_label}</div>
714 <div class="cal-grid">{heads}{blanks}{days}</div>
715 </div>
716 }.into_any()
717 }
718 Widget::SwipeAction { child, actions } => {
719 let acts: Vec<_> = actions.iter().map(|a| {
721 let token = a.on_tap.clone();
722 let send = send.clone();
723 let cls = format!("swipe-act {}", tone_class(a.tone));
724 let label = a.label.clone();
725 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
726 }).collect();
727 view! {
728 <div class="swipe-row">
729 <div class="swipe-content">{render(child, send)}</div>
730 <div class="swipe-actions">{acts}</div>
731 </div>
732 }.into_any()
733 }
734 Widget::Spacer { size } => {
735 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
736 }
737
738 Widget::Row { children } => {
740 let kids = render_all(children, send);
741 view! { <div class="row">{kids}</div> }.into_any()
742 }
743 Widget::Column { children } => {
744 let kids = render_all(children, send);
745 view! { <div class="col">{kids}</div> }.into_any()
746 }
747 Widget::Card { child, style, on_press } => {
748 let class = format!("card {}", card_class(*style));
749 let body = render(child, send);
750 match on_press {
751 Some(token) => {
752 let (send, token) = (send.clone(), token.clone());
753 view! {
754 <button
755 class=format!("{class} card-tappable")
756 on:click=move |_| send(Action::Fired { token: token.clone() })
757 >
758 {body}
759 </button>
760 }
761 .into_any()
762 }
763 None => view! { <div class=class>{body}</div> }.into_any(),
764 }
765 }
766 Widget::Box { children, align, scrim } => {
770 let acls = align_class(*align);
771 if *scrim && children.len() > 1 {
772 let bg = render(&children[0], send);
773 let content = render_all(&children[1..], send);
774 view! {
775 <div class=format!("box box-scrim {acls}")>
776 {bg}
777 <div class="scrim"></div>
778 <div class="box-content">{content}</div>
779 </div>
780 }
781 .into_any()
782 } else {
783 let kids = render_all(children, send);
784 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
785 }
786 }
787 Widget::Grid { children } => {
788 let kids = render_all(children, send);
789 view! { <div class="grid">{kids}</div> }.into_any()
790 }
791 Widget::Scroller { children } => {
792 let kids = render_all(children, send);
793 view! { <div class="scroller">{kids}</div> }.into_any()
794 }
795 Widget::Split { primary, detail, show_detail, on_back } => {
799 let p = render(primary, send);
800 let d = render(detail, send);
801 let back_btn = on_back.clone().map(|t| {
802 let send = send.clone();
803 view! { <button class="split-back" on:click=move |_| send(Action::Fired { token: t.clone() })>"‹ Back"</button> }
804 });
805 view! {
806 <div class="split" data-detail=show_detail.then_some("1")>
807 <div class="split-primary">{p}</div>
808 <div class="split-detail">{back_btn}{d}</div>
809 </div>
810 }.into_any()
811 }
812 Widget::A11y { child, label, hint, role } => {
815 let body = render(child, send);
816 let role_attr = role.map(a11y_role_aria).unwrap_or("group");
817 view! {
818 <div class="a11y" role=role_attr aria-label=label.clone() title=hint.clone()>
819 {body}
820 </div>
821 }.into_any()
822 }
823 Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing } => {
828 let kids = render_all(children, send);
829 let refresh_btn = on_refresh.clone().map(|token| {
830 let send = send.clone();
831 view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻ Refresh"</button> }
832 });
833 let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
834 let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
835 let load_more_btn = (!*loading && *has_more)
836 .then(|| on_load_more.clone())
837 .flatten()
838 .map(|token| {
839 let send = send.clone();
840 view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>"Load more"</button> }
841 });
842 let end_cap = (!*has_more && on_load_more.is_some()).then(|| view! { <div class="lazylist-end">"End of list"</div> });
843 view! {
844 <div class="lazylist">
845 {refresh_btn}
846 {refresh_bar}
847 {kids}
848 {loading_bar}
849 {load_more_btn}
850 {end_cap}
851 </div>
852 }.into_any()
853 }
854
855 Widget::Button { label, style, on_press } => {
857 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
858 let class = format!("btn {}", button_class(*style));
859 view! {
860 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
861 {label}
862 </button>
863 }
864 .into_any()
865 }
866 Widget::IconButton { icon, on_press } => {
867 let (send, token) = (send.clone(), on_press.clone());
868 let glyph = icon_glyph(*icon);
869 view! {
870 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
871 {glyph}
872 </button>
873 }
874 .into_any()
875 }
876 Widget::Chip { label, selected, on_press } => {
877 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
878 let class = if *selected { "chip selected" } else { "chip" };
879 view! {
880 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
881 {label}
882 </button>
883 }
884 .into_any()
885 }
886 Widget::TextField { id, placeholder, value, kind, error } => {
887 let (send, id) = (send.clone(), id.clone());
888 let (placeholder, value) = (placeholder.clone(), value.clone());
889 let invalid = error.is_some();
890 let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
891 let (itype, imode): (&str, &str) = match kind {
893 FieldKind::Secure => ("password", ""),
894 FieldKind::Email => ("email", "email"),
895 FieldKind::Number => ("text", "numeric"),
896 FieldKind::Decimal => ("text", "decimal"),
897 FieldKind::Phone => ("tel", "tel"),
898 FieldKind::Url => ("url", "url"),
899 FieldKind::Text | FieldKind::Multiline => ("text", ""),
900 };
901 let field_class = if invalid { "field field-invalid" } else { "field" };
902 let control = if matches!(kind, FieldKind::Multiline) {
903 view! {
904 <textarea
905 class=field_class
906 rows="3"
907 placeholder=placeholder
908 prop:value=value
909 on:input=move |ev| send(Action::Input {
910 id: id.clone(),
911 value: InputValue::Text(event_target_value(&ev)),
912 })
913 ></textarea>
914 }
915 .into_any()
916 } else {
917 view! {
918 <input
919 class=field_class
920 r#type=itype
921 inputmode=imode
922 placeholder=placeholder
923 prop:value=value
924 on:input=move |ev| send(Action::Input {
925 id: id.clone(),
926 value: InputValue::Text(event_target_value(&ev)),
927 })
928 />
929 }
930 .into_any()
931 };
932 view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
933 }
934 Widget::SearchField { id, placeholder, value } => {
935 let (send, id) = (send.clone(), id.clone());
936 let (placeholder, value) = (placeholder.clone(), value.clone());
937 view! {
938 <div class="searchfield">
939 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
940 <input
941 class="search-input"
942 placeholder=placeholder
943 prop:value=value
944 on:input=move |ev| send(Action::Input {
945 id: id.clone(),
946 value: InputValue::Text(event_target_value(&ev)),
947 })
948 />
949 </div>
950 }
951 .into_any()
952 }
953 Widget::Segmented { segments } => {
954 let segs: Vec<AnyView> = segments
955 .iter()
956 .map(|s| {
957 let (send, token) = (send.clone(), s.on_select.clone());
958 let class = if s.selected { "segment selected" } else { "segment" };
959 let label = s.label.clone();
960 view! {
961 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
962 {label}
963 </button>
964 }
965 .into_any()
966 })
967 .collect();
968 view! { <div class="segmented">{segs}</div> }.into_any()
969 }
970 Widget::Toggle { id, label, value } => {
971 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
972 view! {
973 <label class="toggle">
974 {label}
975 <input
976 type="checkbox"
977 role="switch"
978 prop:checked=checked
979 on:change=move |ev| send(Action::Input {
980 id: id.clone(),
981 value: InputValue::Bool(event_target_checked(&ev)),
982 })
983 />
984 </label>
985 }
986 .into_any()
987 }
988 Widget::Checkbox { id, label, value } => {
989 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
990 view! {
991 <label class="check">
992 <input
993 type="checkbox"
994 prop:checked=checked
995 on:change=move |ev| send(Action::Input {
996 id: id.clone(),
997 value: InputValue::Bool(event_target_checked(&ev)),
998 })
999 />
1000 {label}
1001 </label>
1002 }
1003 .into_any()
1004 }
1005 Widget::Slider { id, value, max } => {
1006 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
1007 view! {
1008 <input
1009 class="slider"
1010 type="range"
1011 min="0"
1012 max=max
1013 prop:value=value
1014 on:input=move |ev| send(Action::Input {
1015 id: id.clone(),
1016 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
1017 })
1018 />
1019 }
1020 .into_any()
1021 }
1022 Widget::Stepper { value, on_decrement, on_increment } => {
1023 let send_dec = send.clone();
1024 let send_inc = send.clone();
1025 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
1026 view! {
1027 <div class="stepper">
1028 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
1029 <span class="stepper-value">{*value}</span>
1030 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
1031 </div>
1032 }
1033 .into_any()
1034 }
1035
1036 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
1038 let back_btn = back.clone().map(|token| {
1039 let send = send.clone();
1040 view! {
1041 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
1042 "‹"
1043 </button>
1044 }
1045 });
1046 let tabbar = (!tabs.is_empty()).then(|| {
1047 let tabs: Vec<AnyView> = tabs
1048 .iter()
1049 .map(|tab| {
1050 let (send, token) = (send.clone(), tab.on_select.clone());
1051 let class = if tab.selected { "tab selected" } else { "tab" };
1052 let label = tab.label.clone();
1053 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
1055 view! {
1056 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1057 {icon}
1058 <span class="tab-label">{label}</span>
1059 </button>
1060 }
1061 .into_any()
1062 })
1063 .collect();
1064 view! { <div class="tabbar">{tabs}</div> }
1065 });
1066 let fab_btn = fab.clone().map(|f| {
1068 let (send, token) = (send.clone(), f.on_press.clone());
1069 view! {
1070 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
1071 {icon_glyph(f.icon)}
1072 </button>
1073 }
1074 });
1075 let sheet_overlay = sheet.as_ref().map(|s| {
1077 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
1078 let (title, child) = (s.title.clone(), render(&s.child, send));
1079 view! {
1080 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
1081 <div class="sheet">
1082 <div class="sheet-handle"></div>
1083 <div class="sheet-title">{title}</div>
1084 {child}
1085 </div>
1086 }
1087 });
1088 let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
1091 let refresh_btn = on_refresh.clone().map(|token| {
1094 let send = send.clone();
1095 view! {
1096 <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
1097 }
1098 });
1099 let refresh_bar = refreshing.then(|| {
1100 view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
1101 });
1102 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
1103 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
1106 let (title, body) = (title.clone(), render(body, send));
1107 view! {
1108 <div class=class style=theme_style>
1109 <div class="topbar">
1110 {back_btn}
1111 <span class="title">{title}</span>
1112 {refresh_btn}
1113 </div>
1114 <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
1115 {fab_btn}
1116 {tabbar}
1117 {sheet_overlay}
1118 </div>
1119 }
1120 .into_any()
1121 }
1122 }
1123}
1124
1125fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
1127 children.iter().map(|c| render(c, send)).collect()
1128}
1129
1130thread_local! {
1131 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
1136
1137 static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
1141}
1142
1143fn theme_css(t: &Theme) -> String {
1148 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
1149 let radius = match t.corner {
1150 Corner::None => "0px",
1151 Corner::Small => "8px",
1152 Corner::Medium => "14px",
1153 Corner::Large => "22px",
1154 };
1155 let (gap, pad) = match t.density {
1156 Density::Compact => ("8px", "10px"),
1157 Density::Comfortable => ("12px", "14px"),
1158 };
1159 let font = match t.font {
1160 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
1161 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
1162 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
1163 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
1164 };
1165 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
1167 format!(
1168 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
1169 --accent2:rgb({ar},{ag},{ab});\
1170 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
1171 --gap:{gap};--pad:{pad};--font:{font};"
1172 )
1173}
1174
1175fn nav_class(route: &str, depth: u32) -> &'static str {
1182 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
1183 if route == prev_route {
1184 return "";
1185 }
1186 let dir = if depth > *prev_depth {
1187 ["nav-push-a", "nav-push-b"]
1188 } else if depth < *prev_depth {
1189 ["nav-pop-a", "nav-pop-b"]
1190 } else {
1191 ["nav-fade-a", "nav-fade-b"]
1192 };
1193 *toggle = !*toggle;
1194 *prev_route = route.to_string();
1195 *prev_depth = depth;
1196 dir[usize::from(*toggle)]
1197 })
1198}
1199
1200fn text_class(s: TextStyle) -> &'static str {
1203 match s {
1204 TextStyle::Title => "t-title",
1205 TextStyle::Subtitle => "t-subtitle",
1206 TextStyle::Caption => "t-caption",
1207 TextStyle::Emphasis => "t-emphasis",
1208 TextStyle::Body => "t-body",
1209 }
1210}
1211
1212fn button_class(s: ButtonStyle) -> &'static str {
1213 match s {
1214 ButtonStyle::Filled => "btn-filled",
1215 ButtonStyle::Outlined => "btn-outlined",
1216 ButtonStyle::Text => "btn-text",
1217 }
1218}
1219
1220fn card_class(s: CardStyle) -> &'static str {
1221 match s {
1222 CardStyle::Elevated => "card-elevated",
1223 CardStyle::Outlined => "card-outlined",
1224 CardStyle::Filled => "card-filled",
1225 CardStyle::Brand => "card-brand",
1226 }
1227}
1228
1229fn a11y_role_aria(role: A11yRole) -> &'static str {
1230 match role {
1231 A11yRole::Button => "button",
1232 A11yRole::Link => "link",
1233 A11yRole::Image => "img",
1234 A11yRole::Header => "heading",
1235 A11yRole::Adjustable => "slider",
1236 }
1237}
1238
1239fn tone_class(t: Tone) -> &'static str {
1240 match t {
1241 Tone::Neutral => "tone-neutral",
1242 Tone::Success => "tone-success",
1243 Tone::Warning => "tone-warning",
1244 Tone::Danger => "tone-danger",
1245 Tone::Info => "tone-info",
1246 }
1247}
1248
1249fn spacer_class(s: Spacing) -> &'static str {
1250 match s {
1251 Spacing::Xs => "sp-xs",
1252 Spacing::Sm => "sp-sm",
1253 Spacing::Md => "sp-md",
1254 Spacing::Lg => "sp-lg",
1255 Spacing::Xl => "sp-xl",
1256 }
1257}
1258
1259fn icon_glyph(i: Icon) -> &'static str {
1260 match i {
1261 Icon::Delete => "🗑",
1262 Icon::Add => "+",
1263 Icon::Edit => "✏️",
1264 Icon::Close => "✕",
1265 Icon::Settings => "⚙",
1266 Icon::Check => "✓",
1267 Icon::Star => "★",
1268 Icon::Info => "ℹ",
1269 Icon::Home => "⌂",
1270 Icon::Search => "🔍",
1271 Icon::Menu => "☰",
1272 Icon::Filter => "⚟",
1273 Icon::Back => "‹",
1274 Icon::Forward => "›",
1275 Icon::Down => "⌄",
1276 Icon::Bell => "🔔",
1277 Icon::Cart => "🛒",
1278 Icon::Share => "↗",
1279 Icon::Heart => "♡",
1280 Icon::HeartFilled => "♥",
1281 Icon::Person => "👤",
1282 Icon::People => "👥",
1283 Icon::Phone => "📞",
1284 Icon::Mail => "✉",
1285 Icon::Calendar => "📅",
1286 Icon::Clock => "🕑",
1287 Icon::MapPin => "📍",
1288 Icon::Camera => "📷",
1289 Icon::Photo => "🖼",
1290 Icon::Play => "▶",
1291 Icon::Scissors => "✂",
1292 }
1293}
1294
1295fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
1296 let shape = match shape {
1297 ImageShape::Square => "img-square",
1298 ImageShape::Rounded => "img-rounded",
1299 ImageShape::Circle => "img-circle",
1300 };
1301 let ratio = match ratio {
1302 ImageRatio::Wide => "ratio-wide",
1303 ImageRatio::Square => "ratio-square",
1304 ImageRatio::Tall => "ratio-tall",
1305 };
1306 format!("img {shape} {ratio}")
1307}
1308
1309fn dot_class(c: ProjectColor) -> &'static str {
1310 match c {
1311 ProjectColor::Indigo => "dot-indigo",
1312 ProjectColor::Teal => "dot-teal",
1313 ProjectColor::Coral => "dot-coral",
1314 ProjectColor::Amber => "dot-amber",
1315 ProjectColor::Lime => "dot-lime",
1316 ProjectColor::Pink => "dot-pink",
1317 }
1318}
1319
1320fn align_class(a: BoxAlign) -> &'static str {
1321 match a {
1322 BoxAlign::TopStart => "align-top-start",
1323 BoxAlign::TopEnd => "align-top-end",
1324 BoxAlign::Center => "align-center",
1325 BoxAlign::BottomStart => "align-bottom-start",
1326 BoxAlign::BottomCenter => "align-bottom-center",
1327 BoxAlign::BottomEnd => "align-bottom-end",
1328 }
1329}
1330
1331const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
1335
1336fn hex(c: Rgb) -> String {
1337 format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
1338}
1339
1340fn chart_color(i: usize, s: &ChartSeries) -> String {
1342 match s.color {
1343 Some(c) => hex(c),
1344 None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
1345 None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
1346 }
1347}
1348
1349fn chart_mag(s: &ChartSeries) -> f32 {
1351 s.values.iter().copied().sum()
1352}
1353
1354fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
1356 (cx + r * ang.sin(), cy - r * ang.cos())
1357}
1358
1359fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1361 let (x0, y0) = polar(cx, cy, r, a0);
1362 let (x1, y1) = polar(cx, cy, r, a1);
1363 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1364 format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
1365}
1366
1367fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1369 let (x0, y0) = polar(cx, cy, r, a0);
1370 let (x1, y1) = polar(cx, cy, r, a1);
1371 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1372 format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
1373}
1374
1375fn fmt_tick(v: f32) -> String {
1376 if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
1377}
1378
1379fn is_cartesian(style: ChartStyle) -> bool {
1380 matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
1381}
1382
1383fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
1385 match style {
1386 ChartStyle::StackedBar => (0..nslots)
1387 .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
1388 .fold(0.0, f32::max)
1389 .max(1e-6),
1390 ChartStyle::StackedBar100 => 1.0,
1391 _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
1392 }
1393}
1394
1395fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
1396 let mut nodes: Vec<AnyView> = Vec::new();
1398 if axis {
1399 for k in 0..=4 {
1400 let y = 2.0 + k as f32 * (46.0 / 4.0);
1401 nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
1402 }
1403 }
1404 match style {
1405 ChartStyle::Line => {
1406 for (i, s) in series.iter().enumerate() {
1407 let n = s.values.len().max(1);
1408 let pts = s.values.iter().enumerate().map(|(j, v)| {
1409 let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
1410 let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
1411 format!("{x:.2},{y:.2}")
1412 }).collect::<Vec<_>>().join(" ");
1413 let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
1414 nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
1415 }
1416 }
1417 ChartStyle::Bar => {
1418 let sw = 100.0 / nslots as f32;
1419 let ns = series.len().max(1);
1420 for (i, s) in series.iter().enumerate() {
1421 let st = format!("fill:{}", chart_color(i, s));
1422 for (j, v) in s.values.iter().enumerate() {
1423 let h = (v / max).clamp(0.0, 1.0) * 46.0;
1424 let bw = sw * 0.8 / ns as f32;
1425 let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
1426 let y = 48.0 - h;
1427 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());
1428 }
1429 }
1430 }
1431 ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
1432 let sw = 100.0 / nslots as f32;
1433 for j in 0..nslots {
1434 let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
1435 let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
1436 let mut acc = 0.0_f32;
1437 for (i, s) in series.iter().enumerate() {
1438 let v = *s.values.get(j).unwrap_or(&0.0);
1439 let h = (v / denom).clamp(0.0, 1.0) * 46.0;
1440 let x = j as f32 * sw + sw * 0.15;
1441 let bw = sw * 0.7;
1442 let y = 48.0 - acc - h;
1443 let st = format!("fill:{}", chart_color(i, s));
1444 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());
1445 acc += h;
1446 }
1447 }
1448 }
1449 _ => {}
1450 }
1451 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
1452}
1453
1454fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
1455 use std::f32::consts::PI;
1456 let mut nodes: Vec<AnyView> = Vec::new();
1457 match style {
1458 ChartStyle::Pie | ChartStyle::Donut => {
1459 let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
1460 let mut a = 0.0_f32;
1461 for (i, s) in series.iter().enumerate() {
1462 let frac = chart_mag(s) / total;
1463 let st = format!("fill:{}", chart_color(i, s));
1464 if frac >= 0.999 {
1465 nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
1466 } else if frac > 0.0 {
1467 let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
1468 nodes.push(view! { <path d=d style=st></path> }.into_any());
1469 }
1470 a += frac * 2.0 * PI;
1471 }
1472 if matches!(style, ChartStyle::Donut) {
1473 nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
1474 }
1475 }
1476 ChartStyle::Rings => {
1477 let n = series.len().max(1);
1478 for (i, s) in series.iter().enumerate() {
1479 let r = 45.0 - i as f32 * (34.0 / n as f32);
1480 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1481 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1482 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());
1483 let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
1484 if prog >= 0.999 {
1485 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
1486 } else if prog > 0.0 {
1487 let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
1488 nodes.push(view! { <path d=d style=st></path> }.into_any());
1489 }
1490 }
1491 }
1492 ChartStyle::Gauge => {
1493 let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
1494 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1495 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1496 let a0 = -0.75 * PI; let a1 = 0.75 * PI;
1498 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());
1499 if prog > 0.0 {
1500 let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
1501 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
1502 }
1503 let pct = format!("{}%", (prog * 100.0).round() as i64);
1504 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());
1505 }
1506 _ => {}
1507 }
1508 view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
1509}
1510
1511fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
1512 let cartesian = is_cartesian(style);
1513 let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
1514 let max = cartesian_max(series, style, nslots);
1515
1516 let plot = if cartesian {
1517 let svg = cartesian_svg(series, style, axis, max, nslots);
1518 let yaxis = if axis {
1519 let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
1520 .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
1521 .collect();
1522 Some(view! { <div class="chart-yaxis">{ticks}</div> })
1523 } else {
1524 None
1525 };
1526 view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
1527 } else {
1528 circular_svg(series, style).into_any()
1529 };
1530
1531 let label_row = if cartesian && !labels.is_empty() {
1532 let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
1533 Some(view! { <div class="chart-labels">{items}</div> })
1534 } else {
1535 None
1536 };
1537
1538 let legend_row = if legend {
1539 let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
1540 let sw = format!("background:{}", chart_color(i, s));
1541 let name = s.name.clone();
1542 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1543 }).collect();
1544 Some(view! { <div class="chart-legend">{items}</div> })
1545 } else {
1546 None
1547 };
1548
1549 view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
1550}
1551
1552const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
1556 [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
1557
1558fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
1560 match r.color {
1561 Some(c) => (c.r, c.g, c.b),
1562 None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
1563 }
1564}
1565
1566fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
1568 let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
1569 if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
1570}
1571
1572fn region_color(i: usize, r: &ChartRegion) -> String {
1573 let (r8, g8, b8) = region_rgb(i, r);
1574 format!("#{r8:02x}{g8:02x}{b8:02x}")
1575}
1576
1577fn region_chart_view(
1581 regions: &[ChartRegion],
1582 ticks: &[ChartTick],
1583 x_max: f32,
1584 y_max: f32,
1585 ref_lines: &[ChartRefLine],
1586 bracket: &Option<ChartBracket>,
1587 legend: &[ChartLegendItem],
1588) -> AnyView {
1589 let xm = x_max.max(1e-6);
1590 let ym = y_max.max(1e-6);
1591
1592 let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
1593 let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
1594 let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
1595 let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
1596 let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
1597 let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
1598 let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
1599 let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
1600 let label = r.label.clone();
1601 view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
1602 }).collect();
1603
1604 let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
1607 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1608 let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
1609 view! { <div class=cls style=style></div> }
1610 }).collect();
1611 let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
1612 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1613 let label = rl.label.clone();
1614 view! { <div class="rchart-chip" style=style>{label}</div> }
1615 }).collect();
1616
1617 let bracket_div = bracket.as_ref().map(|b| {
1618 let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
1619 let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
1620 let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
1621 let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
1622 view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
1623 });
1624
1625 let yticks: Vec<_> = (0..=4).rev().map(|k| {
1626 let v = ym * k as f32 / 4.0;
1627 view! { <span class="chart-tick">{fmt_tick(v)}</span> }
1628 }).collect();
1629
1630 let xticks: Vec<_> = ticks.iter().map(|t| {
1631 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1632 let label = t.label.clone();
1633 view! { <span class="rchart-xtick" style=style>{label}</span> }
1634 }).collect();
1635
1636 let ytick_marks: Vec<_> = (0..=4).map(|k| {
1639 let style = format!("bottom:{:.3}%", k as f32 * 25.0);
1640 view! { <div class="rchart-ytick" style=style></div> }
1641 }).collect();
1642 let xtick_marks: Vec<_> = ticks.iter().map(|t| {
1643 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1644 view! { <div class="rchart-xtickmark" style=style></div> }
1645 }).collect();
1646
1647 let legend_row = if legend.is_empty() {
1648 None
1649 } else {
1650 let items: Vec<_> = legend.iter().map(|l| {
1651 let sw = format!("background:{}", hex(l.color));
1652 let name = l.label.clone();
1653 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1654 }).collect();
1655 Some(view! { <div class="chart-legend">{items}</div> })
1656 };
1657
1658 view! {
1659 <div class="rchart">
1660 <div class="rchart-row">
1661 <div class="rchart-yaxis">{yticks}</div>
1662 <div class="rchart-plotwrap">
1663 <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
1664 {chip_divs}{bracket_div}
1665 </div>
1666 </div>
1667 <div class="rchart-xaxis">{xticks}</div>
1668 {legend_row}
1669 </div>
1670 }.into_any()
1671}