- Add scene container to AST, lexer, parser, analyzer, and codegen
- Add circle/rect/line as UI elements for physics body declaration
- Compile scene {} to canvas + async WASM init + Rapier2D PhysicsWorld
- Reactive gravity via DS.effect() — bodies wake on gravity change
- Mouse drag interaction with impulse-based body movement
- Compile-time hex color parsing for body colors
- Fix is_signal_ref matching numeric literals (700.value bug)
- Fix body variable uniqueness (next_node_id per body)
- Fix gravity signal detection (check AST Ident before emit_expr)
- Add physics.ds example with 5 bodies + 4 gravity control buttons
- Update DREAMSTACK.md and IMPLEMENTATION_PLAN.md with Phase 10-11
- 39 tests pass across all crates, 22KB output
1560 lines
58 KiB
Rust
1560 lines
58 KiB
Rust
/// JavaScript emitter — generates executable JS from DreamStack AST + signal graph.
|
|
|
|
use std::collections::HashSet;
|
|
use ds_parser::*;
|
|
use ds_analyzer::{SignalGraph, SignalKind, AnalyzedView, InitialValue};
|
|
|
|
pub struct JsEmitter {
|
|
output: String,
|
|
indent: usize,
|
|
node_id_counter: usize,
|
|
/// Non-signal local variables (e.g., for-in loop vars)
|
|
local_vars: HashSet<String>,
|
|
}
|
|
|
|
impl JsEmitter {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
output: String::new(),
|
|
indent: 0,
|
|
node_id_counter: 0,
|
|
local_vars: HashSet::new(),
|
|
}
|
|
}
|
|
|
|
/// Generate a complete HTML page with embedded runtime and compiled app.
|
|
pub fn emit_html(program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String {
|
|
let mut emitter = Self::new();
|
|
let app_js = emitter.emit_program(program, graph, views);
|
|
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>DreamStack App</title>
|
|
<style>
|
|
{CSS_RESET}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="ds-root"></div>
|
|
<script>
|
|
{RUNTIME_JS}
|
|
</script>
|
|
<script>
|
|
{app_js}
|
|
</script>
|
|
</body>
|
|
</html>"#
|
|
)
|
|
}
|
|
|
|
/// Generate the application JS module.
|
|
pub fn emit_program(&mut self, program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String {
|
|
self.output.clear();
|
|
|
|
self.emit_line("// DreamStack compiled output");
|
|
self.emit_line("// Generated by dreamstack compiler v0.1.0");
|
|
self.emit_line("");
|
|
self.emit_line("(function() {");
|
|
self.indent += 1;
|
|
|
|
// Phase 1: Create all signals
|
|
self.emit_line("// ── Signals ──");
|
|
for node in &graph.nodes {
|
|
match &node.kind {
|
|
SignalKind::Source => {
|
|
let init = match &node.initial_value {
|
|
Some(InitialValue::Int(n)) => format!("{n}"),
|
|
Some(InitialValue::Float(n)) => format!("{n}"),
|
|
Some(InitialValue::Bool(b)) => format!("{b}"),
|
|
Some(InitialValue::String(s)) => format!("\"{s}\""),
|
|
None => {
|
|
// Fall back to the let declaration expression
|
|
// (handles List, Record, etc.)
|
|
if let Some(expr) = self.find_let_expr(program, &node.name) {
|
|
// Check if it's a spring() call — Spring is already signal-compatible
|
|
if matches!(expr, Expr::Call(name, _) if name == "spring") {
|
|
let js_expr = self.emit_expr(expr);
|
|
self.emit_line(&format!("const {} = {};", node.name, js_expr));
|
|
continue;
|
|
}
|
|
self.emit_expr(expr)
|
|
} else {
|
|
"null".to_string()
|
|
}
|
|
}
|
|
};
|
|
self.emit_line(&format!("const {} = DS.signal({});", node.name, init));
|
|
}
|
|
SignalKind::Derived => {
|
|
// Find the let declaration to get the expression
|
|
let expr = self.find_let_expr(program, &node.name);
|
|
if let Some(expr) = expr {
|
|
let js_expr = self.emit_expr(expr);
|
|
self.emit_line(&format!(
|
|
"const {} = DS.derived(() => {});",
|
|
node.name, js_expr
|
|
));
|
|
}
|
|
}
|
|
SignalKind::Handler { .. } => {} // Handled later
|
|
}
|
|
}
|
|
|
|
self.emit_line("");
|
|
|
|
// Phase 2a: Component functions
|
|
self.emit_line("// ── Components ──");
|
|
for decl in &program.declarations {
|
|
if let Declaration::Component(comp) = decl {
|
|
let params = comp.props.iter()
|
|
.map(|p| p.name.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
self.emit_line(&format!("function component_{}({}) {{", comp.name, params));
|
|
self.indent += 1;
|
|
let child_var = self.emit_view_expr(&comp.body, graph);
|
|
self.emit_line(&format!("return {};", child_var));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
self.emit_line("");
|
|
}
|
|
}
|
|
|
|
// Phase 2b: Build views
|
|
self.emit_line("// ── Views ──");
|
|
for decl in &program.declarations {
|
|
if let Declaration::View(view) = decl {
|
|
self.emit_view(view, graph);
|
|
}
|
|
}
|
|
|
|
// Phase 3: Event handlers
|
|
self.emit_line("");
|
|
self.emit_line("// ── Handlers ──");
|
|
for decl in &program.declarations {
|
|
if let Declaration::OnHandler(handler) = decl {
|
|
self.emit_handler(handler);
|
|
}
|
|
}
|
|
|
|
// Phase 4: Route view functions
|
|
let routes: Vec<_> = program.declarations.iter()
|
|
.filter_map(|d| if let Declaration::Route(r) = d { Some(r) } else { None })
|
|
.collect();
|
|
|
|
if !routes.is_empty() {
|
|
self.emit_line("");
|
|
self.emit_line("// ── Routes ──");
|
|
for (i, route) in routes.iter().enumerate() {
|
|
self.emit_line(&format!("function route_view_{}() {{", i));
|
|
self.indent += 1;
|
|
let child = self.emit_view_expr(&route.body, graph);
|
|
self.emit_line(&format!("return {};", child));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
}
|
|
}
|
|
|
|
// Phase 5: Mount to DOM
|
|
self.emit_line("");
|
|
self.emit_line("// ── Mount ──");
|
|
|
|
if !routes.is_empty() {
|
|
// Router-based mounting
|
|
self.emit_line("const __root = document.getElementById('ds-root');");
|
|
self.emit_line("let __currentView = null;");
|
|
|
|
// Emit any non-route views as layout (e.g., nav bar)
|
|
for view in views {
|
|
self.emit_line(&format!("__root.appendChild(view_{}());", view.name));
|
|
}
|
|
|
|
self.emit_line("const __routeContainer = document.createElement('div');");
|
|
self.emit_line("__routeContainer.className = 'ds-route-container';");
|
|
self.emit_line("__root.appendChild(__routeContainer);");
|
|
self.emit_line("");
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line("const path = DS.route.value;");
|
|
self.emit_line("__routeContainer.innerHTML = '';");
|
|
|
|
for (i, route) in routes.iter().enumerate() {
|
|
let branch = if i == 0 { "if" } else { "} else if" };
|
|
self.emit_line(&format!(
|
|
"{} (DS.matchRoute(\"{}\", path)) {{",
|
|
branch, route.path
|
|
));
|
|
self.indent += 1;
|
|
self.emit_line(&format!("__routeContainer.appendChild(route_view_{}());", i));
|
|
self.indent -= 1;
|
|
}
|
|
self.emit_line("}");
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
} else if let Some(view) = views.first() {
|
|
self.emit_line(&format!(
|
|
"document.getElementById('ds-root').appendChild(view_{}());",
|
|
view.name
|
|
));
|
|
}
|
|
|
|
// Phase 6: Constraints
|
|
let constraints: Vec<_> = program.declarations.iter()
|
|
.filter_map(|d| if let Declaration::Constrain(c) = d { Some(c) } else { None })
|
|
.collect();
|
|
|
|
if !constraints.is_empty() {
|
|
self.emit_line("");
|
|
self.emit_line("// ── Constraints ──");
|
|
for c in &constraints {
|
|
let expr_js = self.emit_expr(&c.expr);
|
|
// Use view element name or fall back to querySelector
|
|
self.emit_line(&format!(
|
|
"DS.constrain(document.querySelector('[data-ds=\"{}\"]:not(.x)') || document.getElementById('ds-root'), '{}', () => {});",
|
|
c.element, c.prop, expr_js
|
|
));
|
|
}
|
|
}
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("})();");
|
|
|
|
self.output.clone()
|
|
}
|
|
|
|
fn emit_view(&mut self, view: &ViewDecl, graph: &SignalGraph) {
|
|
self.emit_line(&format!("function view_{}() {{", view.name));
|
|
self.indent += 1;
|
|
let root_id = self.emit_view_expr(&view.body, graph);
|
|
self.emit_line(&format!("return {};", root_id));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
}
|
|
|
|
/// Emit a view expression and return the variable name of the created DOM node.
|
|
fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String {
|
|
match expr {
|
|
Expr::Container(container) => {
|
|
// ─── PHYSICS SCENE ───
|
|
if matches!(container.kind, ContainerKind::Scene) {
|
|
return self.emit_scene(container, graph);
|
|
}
|
|
|
|
let node_var = self.next_node_id();
|
|
let tag = match &container.kind {
|
|
ContainerKind::Column => "div",
|
|
ContainerKind::Row => "div",
|
|
ContainerKind::Stack => "div",
|
|
ContainerKind::Panel => "div",
|
|
ContainerKind::Form => "form",
|
|
ContainerKind::List => "ul",
|
|
ContainerKind::Scene => "canvas", // handled above
|
|
ContainerKind::Custom(s) => s,
|
|
};
|
|
|
|
let class = match &container.kind {
|
|
ContainerKind::Column => "ds-column",
|
|
ContainerKind::Row => "ds-row",
|
|
ContainerKind::Stack => "ds-stack",
|
|
ContainerKind::Panel => "ds-panel",
|
|
_ => "",
|
|
};
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('{}');", node_var, tag));
|
|
if !class.is_empty() {
|
|
self.emit_line(&format!("{}.className = '{}';", node_var, class));
|
|
}
|
|
|
|
// Handle container props
|
|
for (key, val) in &container.props {
|
|
let js_val = self.emit_expr(val);
|
|
if graph.name_to_id.contains_key(&js_val) || self.is_signal_ref(&js_val) {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.style.{} = {}.value + 'px'; }});",
|
|
node_var, key, js_val
|
|
));
|
|
} else {
|
|
self.emit_line(&format!("{}.style.{} = {};", node_var, key, js_val));
|
|
}
|
|
}
|
|
|
|
// Emit children
|
|
for child in &container.children {
|
|
let child_var = self.emit_view_expr(child, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", node_var, child_var));
|
|
}
|
|
|
|
node_var
|
|
}
|
|
|
|
Expr::Element(element) => {
|
|
let node_var = self.next_node_id();
|
|
let html_tag = match element.tag.as_str() {
|
|
"text" => "span",
|
|
"button" => "button",
|
|
"input" => "input",
|
|
"image" | "avatar" => "img",
|
|
"link" => "a",
|
|
"label" => "label",
|
|
"spinner" => "div",
|
|
"skeleton" => "div",
|
|
_ => "div",
|
|
};
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('{}');", node_var, html_tag));
|
|
self.emit_line(&format!("{}.className = 'ds-{}';", node_var, element.tag));
|
|
|
|
// Handle text content / arguments
|
|
for arg in &element.args {
|
|
match arg {
|
|
Expr::StringLit(s) => {
|
|
if let Some(StringSegment::Literal(text)) = s.segments.first() {
|
|
self.emit_line(&format!(
|
|
"{}.textContent = \"{}\";",
|
|
node_var,
|
|
text.replace('"', "\\\"")
|
|
));
|
|
}
|
|
}
|
|
Expr::Ident(name) => {
|
|
if self.local_vars.contains(name) {
|
|
// Non-reactive local variable (e.g., for-in loop var)
|
|
self.emit_line(&format!(
|
|
"{}.textContent = {};",
|
|
node_var, name
|
|
));
|
|
} else {
|
|
// Reactive text binding!
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.textContent = {}.value; }});",
|
|
node_var, name
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
let js = self.emit_expr(arg);
|
|
self.emit_line(&format!("{}.textContent = {};", node_var, js));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle props (event handlers, attributes)
|
|
for (key, val) in &element.props {
|
|
match key.as_str() {
|
|
"click" | "input" | "change" | "submit" | "keydown" | "keyup" | "mousedown" | "mouseup" => {
|
|
let handler_js = self.emit_event_handler_expr(val);
|
|
let dom_event = match key.as_str() {
|
|
"click" => "click",
|
|
"input" => "input",
|
|
"change" => "change",
|
|
"submit" => "submit",
|
|
"keydown" => "keydown",
|
|
"keyup" => "keyup",
|
|
"mousedown" => "mousedown",
|
|
"mouseup" => "mouseup",
|
|
_ => key,
|
|
};
|
|
self.emit_line(&format!(
|
|
"{}.addEventListener('{}', (e) => {{ {}; DS.flush(); }});",
|
|
node_var, dom_event, handler_js
|
|
));
|
|
}
|
|
"bind" => {
|
|
// Two-way binding: signal <-> input
|
|
// Use raw signal name (not emit_expr which adds .value)
|
|
let signal_name = match val {
|
|
Expr::Ident(name) => name.clone(),
|
|
_ => self.emit_expr(val),
|
|
};
|
|
// Signal -> input
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.value = {}.value; }});",
|
|
node_var, signal_name
|
|
));
|
|
// Input -> signal
|
|
self.emit_line(&format!(
|
|
"{}.addEventListener('input', (e) => {{ {}.value = e.target.value; DS.flush(); }});",
|
|
node_var, signal_name
|
|
));
|
|
}
|
|
"class" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.className += ' ' + {};", node_var, js));
|
|
}
|
|
"placeholder" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.placeholder = {};", node_var, js));
|
|
}
|
|
"value" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.value = {}.value; }});",
|
|
node_var, js
|
|
));
|
|
}
|
|
"style" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.style.cssText = {};", node_var, js));
|
|
}
|
|
"disabled" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.disabled = {}.value; }});",
|
|
node_var, js
|
|
));
|
|
}
|
|
_ => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.setAttribute('{}', {});", node_var, key, js));
|
|
}
|
|
}
|
|
}
|
|
|
|
node_var
|
|
}
|
|
|
|
Expr::When(cond, body) => {
|
|
let anchor_var = self.next_node_id();
|
|
let container_var = self.next_node_id();
|
|
let cond_js = self.emit_expr(cond);
|
|
|
|
self.emit_line(&format!("const {} = document.createComment('when');", anchor_var));
|
|
self.emit_line(&format!("let {} = null;", container_var));
|
|
|
|
// Build the conditional child
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const show = {};", cond_js));
|
|
self.emit_line(&format!("if (show && !{}) {{", container_var));
|
|
self.indent += 1;
|
|
let child_var = self.emit_view_expr(body, graph);
|
|
self.emit_line(&format!("{} = {};", container_var, child_var));
|
|
self.emit_line(&format!(
|
|
"{}.parentNode.insertBefore({}, {}.nextSibling);",
|
|
anchor_var, container_var, anchor_var
|
|
));
|
|
self.indent -= 1;
|
|
self.emit_line(&format!("}} else if (!show && {}) {{", container_var));
|
|
self.indent += 1;
|
|
self.emit_line(&format!("{}.remove();", container_var));
|
|
self.emit_line(&format!("{} = null;", container_var));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
anchor_var
|
|
}
|
|
|
|
// ForIn reactive list: `for item in items -> body`
|
|
Expr::ForIn { item, index, iter, body } => {
|
|
let container_var = self.next_node_id();
|
|
let iter_js = self.emit_expr(iter);
|
|
let iter_var = self.next_node_id(); // unique name to avoid shadowing
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
|
self.emit_line(&format!("{}.className = 'ds-for-list';", container_var));
|
|
|
|
// Reactive effect that re-renders the list when the iterable changes
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const {} = {};", iter_var, iter_js));
|
|
self.emit_line(&format!("{}.innerHTML = '';", container_var));
|
|
|
|
let idx_var = index.as_deref().unwrap_or("_idx");
|
|
self.emit_line(&format!("const __list = ({0} && {0}.value !== undefined) ? {0}.value : (Array.isArray({0}) ? {0} : []);", iter_var));
|
|
self.emit_line(&format!("__list.forEach(({item}, {idx_var}) => {{"));
|
|
self.indent += 1;
|
|
|
|
// Mark loop vars as non-reactive so text bindings use plain access
|
|
self.local_vars.insert(item.clone());
|
|
if let Some(idx) = index {
|
|
self.local_vars.insert(idx.clone());
|
|
}
|
|
|
|
let child_var = self.emit_view_expr(body, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", container_var, child_var));
|
|
|
|
// Clean up
|
|
self.local_vars.remove(item);
|
|
if let Some(idx) = index {
|
|
self.local_vars.remove(idx);
|
|
}
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
container_var
|
|
}
|
|
|
|
// Component usage: `<Card title="hello" />`
|
|
Expr::ComponentUse { name, props, children } => {
|
|
let args = props.iter()
|
|
.map(|(k, v)| self.emit_expr(v))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let node_var = self.next_node_id();
|
|
self.emit_line(&format!("const {} = component_{}({});", node_var, name, args));
|
|
node_var
|
|
}
|
|
|
|
Expr::Match(scrutinee, arms) => {
|
|
let container_var = self.next_node_id();
|
|
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
|
self.emit_line(&format!("{}.className = 'ds-match';", container_var));
|
|
|
|
let scrutinee_js = self.emit_expr(scrutinee);
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const val = {};", scrutinee_js));
|
|
self.emit_line(&format!("{}.innerHTML = '';", container_var));
|
|
|
|
for (i, arm) in arms.iter().enumerate() {
|
|
let prefix = if i == 0 { "if" } else { "} else if" };
|
|
let pattern_js = self.emit_pattern_check(&arm.pattern, "val");
|
|
self.emit_line(&format!("{} ({}) {{", prefix, pattern_js));
|
|
self.indent += 1;
|
|
let child = self.emit_view_expr(&arm.body, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", container_var, child));
|
|
self.indent -= 1;
|
|
}
|
|
self.emit_line("}");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
container_var
|
|
}
|
|
|
|
// Fallback: just create a text node
|
|
_ => {
|
|
let node_var = self.next_node_id();
|
|
let js = self.emit_expr(expr);
|
|
self.emit_line(&format!(
|
|
"const {} = document.createTextNode({});",
|
|
node_var, js
|
|
));
|
|
node_var
|
|
}
|
|
}
|
|
}
|
|
|
|
fn emit_handler(&mut self, handler: &OnHandler) {
|
|
let handler_js = self.emit_event_handler_expr(&handler.body);
|
|
if let Some(param) = &handler.param {
|
|
self.emit_line(&format!(
|
|
"DS.onEvent('{}', ({}) => {{ {}; DS.flush(); }});",
|
|
handler.event, param, handler_js
|
|
));
|
|
} else {
|
|
self.emit_line(&format!(
|
|
"DS.onEvent('{}', () => {{ {}; DS.flush(); }});",
|
|
handler.event, handler_js
|
|
));
|
|
}
|
|
}
|
|
|
|
// ── Expression emitters ─────────────────────────────
|
|
|
|
fn emit_expr(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::IntLit(n) => format!("{n}"),
|
|
Expr::FloatLit(n) => format!("{n}"),
|
|
Expr::BoolLit(b) => format!("{b}"),
|
|
Expr::StringLit(s) => {
|
|
if s.segments.len() == 1 {
|
|
if let StringSegment::Literal(text) = &s.segments[0] {
|
|
return format!("\"{}\"", text.replace('"', "\\\""));
|
|
}
|
|
}
|
|
// Template literal with interpolation
|
|
let mut parts = Vec::new();
|
|
for seg in &s.segments {
|
|
match seg {
|
|
StringSegment::Literal(text) => parts.push(text.clone()),
|
|
StringSegment::Interpolation(expr) => {
|
|
parts.push(format!("${{{}}}", self.emit_expr(expr)));
|
|
}
|
|
}
|
|
}
|
|
format!("`{}`", parts.join(""))
|
|
}
|
|
Expr::Ident(name) => format!("{name}.value"),
|
|
Expr::DotAccess(base, field) => {
|
|
let base_js = self.emit_expr(base);
|
|
format!("{base_js}.{field}")
|
|
}
|
|
Expr::BinOp(left, op, right) => {
|
|
let l = self.emit_expr(left);
|
|
let r = self.emit_expr(right);
|
|
let op_str = match op {
|
|
BinOp::Add => "+",
|
|
BinOp::Sub => "-",
|
|
BinOp::Mul => "*",
|
|
BinOp::Div => "/",
|
|
BinOp::Mod => "%",
|
|
BinOp::Eq => "===",
|
|
BinOp::Neq => "!==",
|
|
BinOp::Lt => "<",
|
|
BinOp::Gt => ">",
|
|
BinOp::Lte => "<=",
|
|
BinOp::Gte => ">=",
|
|
BinOp::And => "&&",
|
|
BinOp::Or => "||",
|
|
};
|
|
format!("({l} {op_str} {r})")
|
|
}
|
|
Expr::UnaryOp(op, inner) => {
|
|
let inner_js = self.emit_expr(inner);
|
|
match op {
|
|
UnaryOp::Neg => format!("(-{inner_js})"),
|
|
UnaryOp::Not => format!("(!{inner_js})"),
|
|
}
|
|
}
|
|
Expr::Call(name, args) => {
|
|
let args_js: Vec<String> = args.iter().map(|a| self.emit_expr(a)).collect();
|
|
// Built-in functions map to DS.xxx()
|
|
let fn_name = match name.as_str() {
|
|
"navigate" => "DS.navigate",
|
|
"spring" => "DS.spring",
|
|
_ => name,
|
|
};
|
|
format!("{}({})", fn_name, args_js.join(", "))
|
|
}
|
|
Expr::If(cond, then_b, else_b) => {
|
|
let c = self.emit_expr(cond);
|
|
let t = self.emit_expr(then_b);
|
|
let e = self.emit_expr(else_b);
|
|
format!("({c} ? {t} : {e})")
|
|
}
|
|
Expr::Lambda(params, body) => {
|
|
let body_js = self.emit_expr(body);
|
|
format!("({}) => {}", params.join(", "), body_js)
|
|
}
|
|
Expr::Record(fields) => {
|
|
let fields_js: Vec<String> = fields
|
|
.iter()
|
|
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v)))
|
|
.collect();
|
|
format!("{{ {} }}", fields_js.join(", "))
|
|
}
|
|
Expr::List(items) => {
|
|
let items_js: Vec<String> = items.iter().map(|i| self.emit_expr(i)).collect();
|
|
format!("[{}]", items_js.join(", "))
|
|
}
|
|
_ => "null".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Emit an event handler expression (assignment, etc.)
|
|
fn emit_event_handler_expr(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::Assign(target, op, value) => {
|
|
let target_js = match target.as_ref() {
|
|
Expr::Ident(name) => name.clone(),
|
|
Expr::DotAccess(base, field) => {
|
|
format!("{}.{}", self.emit_expr(base), field)
|
|
}
|
|
_ => self.emit_expr(target),
|
|
};
|
|
let value_js = self.emit_expr(value);
|
|
match op {
|
|
AssignOp::Set => format!("{target_js}.value = {value_js}"),
|
|
AssignOp::AddAssign => format!("{target_js}.value += {value_js}"),
|
|
AssignOp::SubAssign => format!("{target_js}.value -= {value_js}"),
|
|
}
|
|
}
|
|
Expr::Block(exprs) => {
|
|
let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
|
|
stmts.join("; ")
|
|
}
|
|
_ => self.emit_expr(expr),
|
|
}
|
|
}
|
|
|
|
/// Emit a pattern matching condition check.
|
|
fn emit_pattern_check(&self, pattern: &Pattern, scrutinee: &str) -> String {
|
|
match pattern {
|
|
Pattern::Wildcard => "true".to_string(),
|
|
Pattern::Ident(name) => {
|
|
// Bind: always true, but assign
|
|
format!("(({name} = {scrutinee}), true)")
|
|
}
|
|
Pattern::Constructor(name, _fields) => {
|
|
format!("{scrutinee} === '{name}'")
|
|
}
|
|
Pattern::Literal(expr) => {
|
|
let val = self.emit_expr(expr);
|
|
format!("{scrutinee} === {val}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────
|
|
|
|
fn next_node_id(&mut self) -> String {
|
|
let id = self.node_id_counter;
|
|
self.node_id_counter += 1;
|
|
format!("n{id}")
|
|
}
|
|
|
|
fn emit_line(&mut self, line: &str) {
|
|
for _ in 0..self.indent {
|
|
self.output.push_str(" ");
|
|
}
|
|
self.output.push_str(line);
|
|
self.output.push('\n');
|
|
}
|
|
|
|
fn find_let_expr<'a>(&self, program: &'a Program, name: &str) -> Option<&'a Expr> {
|
|
for decl in &program.declarations {
|
|
if let Declaration::Let(let_decl) = decl {
|
|
if let_decl.name == name {
|
|
return Some(&let_decl.value);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_signal_ref(&self, expr: &str) -> bool {
|
|
// Must start with a letter/underscore (not a digit) and contain only ident chars
|
|
!expr.is_empty()
|
|
&& expr.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
|
|
&& expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
}
|
|
|
|
/// Emit a physics scene — canvas + WASM physics engine + animation loop
|
|
fn emit_scene(&mut self, container: &Container, graph: &SignalGraph) -> String {
|
|
let wrapper_var = self.next_node_id();
|
|
let canvas_var = self.next_node_id();
|
|
|
|
// Extract scene props
|
|
let mut scene_width = "800".to_string();
|
|
let mut scene_height = "500".to_string();
|
|
let mut gravity_x_expr = None;
|
|
let mut gravity_y_expr = None;
|
|
let mut gx_is_signal = false;
|
|
let mut gy_is_signal = false;
|
|
|
|
for (key, val) in &container.props {
|
|
match key.as_str() {
|
|
"width" => scene_width = self.emit_expr(val),
|
|
"height" => scene_height = self.emit_expr(val),
|
|
"gravity_x" => {
|
|
gx_is_signal = matches!(val, Expr::Ident(_));
|
|
gravity_x_expr = Some(if let Expr::Ident(name) = val {
|
|
name.clone()
|
|
} else {
|
|
self.emit_expr(val)
|
|
});
|
|
}
|
|
"gravity_y" => {
|
|
gy_is_signal = matches!(val, Expr::Ident(_));
|
|
gravity_y_expr = Some(if let Expr::Ident(name) = val {
|
|
name.clone()
|
|
} else {
|
|
self.emit_expr(val)
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Create wrapper div
|
|
self.emit_line(&format!("const {} = document.createElement('div');", wrapper_var));
|
|
self.emit_line(&format!("{}.className = 'ds-scene-wrapper';", wrapper_var));
|
|
|
|
// Create canvas
|
|
let w_val = if graph.name_to_id.contains_key(&scene_width) || self.is_signal_ref(&scene_width) {
|
|
format!("{}.value", scene_width)
|
|
} else {
|
|
scene_width.clone()
|
|
};
|
|
let h_val = if graph.name_to_id.contains_key(&scene_height) || self.is_signal_ref(&scene_height) {
|
|
format!("{}.value", scene_height)
|
|
} else {
|
|
scene_height.clone()
|
|
};
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('canvas');", canvas_var));
|
|
self.emit_line(&format!("{}.width = {};", canvas_var, w_val));
|
|
self.emit_line(&format!("{}.height = {};", canvas_var, h_val));
|
|
self.emit_line(&format!("{}.style.width = {} + 'px';", canvas_var, w_val));
|
|
self.emit_line(&format!("{}.style.height = {} + 'px';", canvas_var, h_val));
|
|
self.emit_line(&format!("{}.style.borderRadius = '16px';", canvas_var));
|
|
self.emit_line(&format!("{}.style.background = 'rgba(255,255,255,0.02)';", canvas_var));
|
|
self.emit_line(&format!("{}.style.border = '1px solid rgba(255,255,255,0.06)';", canvas_var));
|
|
self.emit_line(&format!("{}.style.cursor = 'pointer';", canvas_var));
|
|
self.emit_line(&format!("{}.appendChild({});", wrapper_var, canvas_var));
|
|
|
|
// Async IIFE to load WASM and set up physics
|
|
self.emit_line("(async () => {");
|
|
self.indent += 1;
|
|
|
|
self.emit_line("const { default: init, PhysicsWorld } = await import('./pkg/ds_physics.js');");
|
|
self.emit_line("await init();");
|
|
self.emit_line(&format!("const _sceneW = {};", w_val));
|
|
self.emit_line(&format!("const _sceneH = {};", h_val));
|
|
self.emit_line("const _world = new PhysicsWorld(_sceneW, _sceneH);");
|
|
self.emit_line(&format!("const _ctx = {}.getContext('2d');", canvas_var));
|
|
|
|
// Create bodies from child elements
|
|
for child in &container.children {
|
|
if let Expr::Element(element) = child {
|
|
match element.tag.as_str() {
|
|
"circle" => self.emit_scene_circle(element, graph),
|
|
"rect" => self.emit_scene_rect(element, graph),
|
|
_ => {} // skip unknown elements
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wire reactive gravity
|
|
if let Some(ref gx) = gravity_x_expr {
|
|
if gx_is_signal {
|
|
if let Some(ref gy) = gravity_y_expr {
|
|
if gy_is_signal {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity({}.value, {}.value); }});",
|
|
gx, gy
|
|
));
|
|
} else {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity({}.value, {}); }});",
|
|
gx, gy
|
|
));
|
|
}
|
|
} else {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity({}.value, 980); }});",
|
|
gx
|
|
));
|
|
}
|
|
}
|
|
} else if let Some(ref gy) = gravity_y_expr {
|
|
if gy_is_signal {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity(0, {}.value); }});",
|
|
gy
|
|
));
|
|
} else {
|
|
self.emit_line(&format!("_world.set_gravity(0, {});", gy));
|
|
}
|
|
}
|
|
|
|
// Drag interaction
|
|
self.emit_line("let _dragBody = -1, _dragOffX = 0, _dragOffY = 0;");
|
|
self.emit_line("let _lastMX = 0, _lastMY = 0, _velX = 0, _velY = 0;");
|
|
self.emit_line(&format!(
|
|
"{}.addEventListener('mousedown', (e) => {{", canvas_var
|
|
));
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const rect = {}.getBoundingClientRect();", canvas_var));
|
|
self.emit_line("const mx = e.clientX - rect.left, my = e.clientY - rect.top;");
|
|
self.emit_line("_lastMX = mx; _lastMY = my;");
|
|
self.emit_line("for (let b = 0; b < _world.body_count(); b++) {");
|
|
self.indent += 1;
|
|
self.emit_line("const c = _world.get_body_center(b);");
|
|
self.emit_line("const dx = mx - c[0], dy = my - c[1];");
|
|
self.emit_line("if (Math.sqrt(dx*dx + dy*dy) < 80) { _dragBody = b; _dragOffX = dx; _dragOffY = dy; e.preventDefault(); return; }");
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
self.emit_line("window.addEventListener('mousemove', (e) => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const rect = {}.getBoundingClientRect();", canvas_var));
|
|
self.emit_line("const mx = e.clientX - rect.left, my = e.clientY - rect.top;");
|
|
self.emit_line("_velX = mx - _lastMX; _velY = my - _lastMY; _lastMX = mx; _lastMY = my;");
|
|
self.emit_line("if (_dragBody >= 0) { const c = _world.get_body_center(_dragBody); _world.apply_impulse(_dragBody, (mx - _dragOffX - c[0]) * 5000, (my - _dragOffY - c[1]) * 5000); }");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
self.emit_line("window.addEventListener('mouseup', () => { if (_dragBody >= 0) { _world.apply_impulse(_dragBody, _velX * 3000, _velY * 3000); _dragBody = -1; } });");
|
|
|
|
// Event listener for physics impulse
|
|
self.emit_line("DS.onEvent('physics_impulse', (data) => { if (data && data.body !== undefined) _world.apply_impulse(data.body, data.fx || 0, data.fy || 0); });");
|
|
|
|
// Animation loop with draw
|
|
self.emit_line("function _sceneDraw() {");
|
|
self.indent += 1;
|
|
self.emit_line("_ctx.clearRect(0, 0, _sceneW, _sceneH);");
|
|
// Grid
|
|
self.emit_line("_ctx.strokeStyle = 'rgba(255,255,255,0.02)'; _ctx.lineWidth = 1;");
|
|
self.emit_line("for (let x = 0; x < _sceneW; x += 40) { _ctx.beginPath(); _ctx.moveTo(x,0); _ctx.lineTo(x,_sceneH); _ctx.stroke(); }");
|
|
self.emit_line("for (let y = 0; y < _sceneH; y += 40) { _ctx.beginPath(); _ctx.moveTo(0,y); _ctx.lineTo(_sceneW,y); _ctx.stroke(); }");
|
|
// Bodies
|
|
self.emit_line("for (let b = 0; b < _world.body_count(); b++) {");
|
|
self.indent += 1;
|
|
self.emit_line("const pos = _world.get_body_positions(b), col = _world.get_body_color(b), bt = _world.get_body_type(b);");
|
|
self.emit_line("const r = col[0]*255|0, g = col[1]*255|0, bl = col[2]*255|0, a = col[3];");
|
|
self.emit_line("const fill = `rgba(${r},${g},${bl},${a*0.5})`, stroke = `rgba(${r},${g},${bl},${a*0.9})`, glow = `rgba(${r},${g},${bl},0.4)`;");
|
|
|
|
// Rect
|
|
self.emit_line("if (bt === 1 && pos.length >= 8) {");
|
|
self.indent += 1;
|
|
self.emit_line("_ctx.beginPath(); _ctx.moveTo(pos[0],pos[1]); _ctx.lineTo(pos[2],pos[3]); _ctx.lineTo(pos[4],pos[5]); _ctx.lineTo(pos[6],pos[7]); _ctx.closePath();");
|
|
self.emit_line("_ctx.shadowColor = glow; _ctx.shadowBlur = 18; _ctx.fillStyle = fill; _ctx.fill(); _ctx.shadowBlur = 0; _ctx.strokeStyle = stroke; _ctx.lineWidth = 2; _ctx.stroke();");
|
|
self.indent -= 1;
|
|
self.emit_line("} else {");
|
|
self.indent += 1;
|
|
// Circle
|
|
self.emit_line("const pc = _world.get_body_perimeter_count(b);");
|
|
self.emit_line("if (pc >= 3) { _ctx.beginPath(); _ctx.moveTo(pos[0],pos[1]); for (let i=1;i<pc;i++) _ctx.lineTo(pos[i*2],pos[i*2+1]); _ctx.closePath();");
|
|
self.emit_line("_ctx.shadowColor = glow; _ctx.shadowBlur = 20; _ctx.fillStyle = fill; _ctx.fill(); _ctx.shadowBlur = 0; _ctx.strokeStyle = stroke; _ctx.lineWidth = 1.5; _ctx.stroke();");
|
|
self.emit_line("if (pos.length >= (pc+1)*2) { _ctx.beginPath(); _ctx.arc(pos[pc*2],pos[pc*2+1],2.5,0,Math.PI*2); _ctx.fillStyle = stroke; _ctx.fill(); } }");
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
// Boundary
|
|
self.emit_line("_ctx.strokeStyle = 'rgba(99,102,241,0.06)'; _ctx.lineWidth = 2; _ctx.strokeRect(1,1,_sceneW-2,_sceneH-2);");
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
|
|
// Loop
|
|
self.emit_line("function _sceneLoop() { _world.step(1/60); _sceneDraw(); requestAnimationFrame(_sceneLoop); }");
|
|
self.emit_line("requestAnimationFrame(_sceneLoop);");
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("})();");
|
|
|
|
wrapper_var
|
|
}
|
|
|
|
/// Emit a circle body creation inside a scene
|
|
fn emit_scene_circle(&mut self, element: &Element, _graph: &SignalGraph) {
|
|
let mut x = "200".to_string();
|
|
let mut y = "100".to_string();
|
|
let mut radius = "30".to_string();
|
|
let mut color = None;
|
|
let mut segments = "16".to_string();
|
|
let mut pinned = false;
|
|
|
|
for (key, val) in &element.props {
|
|
let js = self.emit_expr(val);
|
|
match key.as_str() {
|
|
"x" => x = js,
|
|
"y" => y = js,
|
|
"radius" => radius = js,
|
|
"color" => color = Some(js),
|
|
"segments" => segments = js,
|
|
"pinned" => pinned = js == "true",
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let body_var = self.next_node_id();
|
|
self.emit_line(&format!(
|
|
"const {} = _world.create_soft_circle({}, {}, {}, {}, 50);",
|
|
body_var, x, y, radius, segments
|
|
));
|
|
|
|
if let Some(c) = color {
|
|
self.emit_scene_set_color(&body_var, c);
|
|
}
|
|
|
|
if pinned {
|
|
self.emit_line(&format!("_world.pin_particle({}, true);", body_var));
|
|
}
|
|
}
|
|
|
|
/// Emit a rect body creation inside a scene
|
|
fn emit_scene_rect(&mut self, element: &Element, _graph: &SignalGraph) {
|
|
let mut x = "200".to_string();
|
|
let mut y = "100".to_string();
|
|
let mut width = "60".to_string();
|
|
let mut height = "40".to_string();
|
|
let mut color = None;
|
|
let mut pinned = false;
|
|
|
|
for (key, val) in &element.props {
|
|
let js = self.emit_expr(val);
|
|
match key.as_str() {
|
|
"x" => x = js,
|
|
"y" => y = js,
|
|
"width" => width = js,
|
|
"height" => height = js,
|
|
"color" => color = Some(js),
|
|
"pinned" => pinned = js == "true",
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let body_var = self.next_node_id();
|
|
self.emit_line(&format!(
|
|
"const {} = _world.create_soft_rect({}, {}, {}, {}, 4, 3, 30);",
|
|
body_var, x, y, width, height
|
|
));
|
|
|
|
if let Some(c) = color {
|
|
self.emit_scene_set_color(&body_var, c);
|
|
}
|
|
|
|
if pinned {
|
|
self.emit_line(&format!("_world.pin_particle({}, true);", body_var));
|
|
}
|
|
}
|
|
|
|
/// Emit set_body_color from a hex color string
|
|
fn emit_scene_set_color(&mut self, body_var: &str, color_str: String) {
|
|
// Parse hex color at compile time or emit runtime parser
|
|
let clean = color_str.replace('"', "").replace('\\', "");
|
|
if clean.starts_with('#') && clean.len() == 7 {
|
|
let r = u8::from_str_radix(&clean[1..3], 16).unwrap_or(128);
|
|
let g = u8::from_str_radix(&clean[3..5], 16).unwrap_or(128);
|
|
let b = u8::from_str_radix(&clean[5..7], 16).unwrap_or(128);
|
|
self.emit_line(&format!(
|
|
"_world.set_body_color({}, {:.3}, {:.3}, {:.3}, 1.0);",
|
|
body_var,
|
|
r as f64 / 255.0,
|
|
g as f64 / 255.0,
|
|
b as f64 / 255.0
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for JsEmitter {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Minimal CSS reset and layout classes.
|
|
const CSS_RESET: &str = r#"
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
|
background: #0a0a0f;
|
|
color: #e2e8f0;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
}
|
|
#ds-root {
|
|
width: 100%;
|
|
max-width: 800px;
|
|
padding: 2rem;
|
|
}
|
|
.ds-column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
.ds-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
}
|
|
.ds-stack {
|
|
position: relative;
|
|
}
|
|
.ds-panel {
|
|
position: absolute;
|
|
}
|
|
.ds-text, .ds-text span {
|
|
font-size: 1.25rem;
|
|
color: #e2e8f0;
|
|
}
|
|
.ds-button, button.ds-button {
|
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 12px;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
|
|
}
|
|
.ds-button:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
|
|
}
|
|
.ds-button:active {
|
|
transform: translateY(0);
|
|
}
|
|
.ds-input, input.ds-input {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
color: #e2e8f0;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 12px;
|
|
font-size: 1rem;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.ds-input:focus {
|
|
border-color: #6366f1;
|
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
|
}
|
|
.ds-spinner {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border: 3px solid rgba(255,255,255,0.1);
|
|
border-top-color: #6366f1;
|
|
border-radius: 50%;
|
|
animation: ds-spin 0.8s linear infinite;
|
|
}
|
|
@keyframes ds-spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
@keyframes ds-fade-in {
|
|
from { opacity: 0; transform: translateY(8px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.ds-fade-in {
|
|
animation: ds-fade-in 0.3s ease-out;
|
|
}
|
|
"#;
|
|
|
|
/// The DreamStack client-side reactive runtime (~3KB).
|
|
const RUNTIME_JS: &str = r#"
|
|
const DS = (() => {
|
|
// ── Signal System ──
|
|
let currentEffect = null;
|
|
let batchDepth = 0;
|
|
let pendingEffects = new Set();
|
|
|
|
class Signal {
|
|
constructor(initialValue) {
|
|
this._value = initialValue;
|
|
this._subscribers = new Set();
|
|
}
|
|
get value() {
|
|
if (currentEffect) {
|
|
this._subscribers.add(currentEffect);
|
|
}
|
|
return this._value;
|
|
}
|
|
set value(newValue) {
|
|
if (this._value === newValue) return;
|
|
this._value = newValue;
|
|
if (batchDepth > 0) {
|
|
for (const sub of this._subscribers) {
|
|
pendingEffects.add(sub);
|
|
}
|
|
} else {
|
|
for (const sub of [...this._subscribers]) {
|
|
sub._run();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class Derived {
|
|
constructor(fn) {
|
|
this._fn = fn;
|
|
this._value = undefined;
|
|
this._dirty = true;
|
|
this._subscribers = new Set();
|
|
this._effect = new Effect(() => {
|
|
this._value = this._fn();
|
|
this._dirty = false;
|
|
// Notify our subscribers
|
|
if (batchDepth > 0) {
|
|
for (const sub of this._subscribers) {
|
|
pendingEffects.add(sub);
|
|
}
|
|
} else {
|
|
for (const sub of [...this._subscribers]) {
|
|
sub._run();
|
|
}
|
|
}
|
|
});
|
|
this._effect._run();
|
|
}
|
|
get value() {
|
|
if (currentEffect) {
|
|
this._subscribers.add(currentEffect);
|
|
}
|
|
return this._value;
|
|
}
|
|
}
|
|
|
|
class Effect {
|
|
constructor(fn) {
|
|
this._fn = fn;
|
|
this._disposed = false;
|
|
}
|
|
_run() {
|
|
if (this._disposed) return;
|
|
const prev = currentEffect;
|
|
currentEffect = this;
|
|
try {
|
|
this._fn();
|
|
} finally {
|
|
currentEffect = prev;
|
|
}
|
|
}
|
|
dispose() {
|
|
this._disposed = true;
|
|
}
|
|
}
|
|
|
|
// ── Public API ──
|
|
function signal(value) {
|
|
return new Signal(value);
|
|
}
|
|
|
|
function derived(fn) {
|
|
return new Derived(fn);
|
|
}
|
|
|
|
function effect(fn) {
|
|
const eff = new Effect(fn);
|
|
eff._run();
|
|
return eff;
|
|
}
|
|
|
|
function batch(fn) {
|
|
batchDepth++;
|
|
try {
|
|
fn();
|
|
} finally {
|
|
batchDepth--;
|
|
if (batchDepth === 0) {
|
|
flush();
|
|
}
|
|
}
|
|
}
|
|
|
|
function flush() {
|
|
const effects = [...pendingEffects];
|
|
pendingEffects.clear();
|
|
for (const eff of effects) {
|
|
eff._run();
|
|
}
|
|
}
|
|
|
|
// ── Event System ──
|
|
const eventHandlers = {};
|
|
|
|
function onEvent(name, handler) {
|
|
if (!eventHandlers[name]) eventHandlers[name] = [];
|
|
eventHandlers[name].push(handler);
|
|
}
|
|
|
|
function emit(name, data) {
|
|
const handlers = eventHandlers[name];
|
|
if (handlers) {
|
|
batch(() => {
|
|
for (const h of handlers) {
|
|
h(data);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Keyed List Reconciliation ──
|
|
function keyedList(container, getItems, keyFn, renderFn) {
|
|
let nodeMap = new Map();
|
|
const eff = effect(() => {
|
|
const raw = getItems();
|
|
const items = (raw && raw.value !== undefined) ? raw.value : (Array.isArray(raw) ? raw : []);
|
|
const newMap = new Map();
|
|
const frag = document.createDocumentFragment();
|
|
items.forEach((item, idx) => {
|
|
const key = keyFn ? keyFn(item, idx) : idx;
|
|
let node = nodeMap.get(key);
|
|
if (!node) {
|
|
node = renderFn(item, idx);
|
|
}
|
|
newMap.set(key, node);
|
|
frag.appendChild(node);
|
|
});
|
|
container.innerHTML = '';
|
|
container.appendChild(frag);
|
|
nodeMap = newMap;
|
|
});
|
|
return eff;
|
|
}
|
|
|
|
// ── Router ──
|
|
const _route = new Signal(window.location.hash.slice(1) || '/');
|
|
window.addEventListener('hashchange', () => {
|
|
_route.value = window.location.hash.slice(1) || '/';
|
|
});
|
|
|
|
function navigate(path) {
|
|
window.location.hash = path;
|
|
}
|
|
|
|
function matchRoute(pattern, path) {
|
|
if (pattern === path) return {};
|
|
const patParts = pattern.split('/');
|
|
const pathParts = path.split('/');
|
|
if (patParts.length !== pathParts.length) return null;
|
|
const params = {};
|
|
for (let i = 0; i < patParts.length; i++) {
|
|
if (patParts[i].startsWith(':')) {
|
|
params[patParts[i].slice(1)] = pathParts[i];
|
|
} else if (patParts[i] !== pathParts[i]) {
|
|
return null;
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
|
|
// ── Async Resources ──
|
|
function resource(fetcher) {
|
|
const state = new Signal({ status: 'loading', data: null, error: null });
|
|
const eff = effect(() => {
|
|
state.value = { status: 'loading', data: state._value.data, error: null };
|
|
Promise.resolve(fetcher())
|
|
.then(data => { state.value = { status: 'ok', data, error: null }; })
|
|
.catch(err => { state.value = { status: 'error', data: null, error: err.message || String(err) }; });
|
|
});
|
|
return state;
|
|
}
|
|
|
|
function fetchJSON(url) {
|
|
return resource(() => fetch(url).then(r => r.json()));
|
|
}
|
|
|
|
// ── Spring Physics Engine ──
|
|
const _activeSprings = new Set();
|
|
let _rafId = null;
|
|
let _lastTime = 0;
|
|
|
|
class Spring {
|
|
constructor({ value = 0, target, stiffness = 170, damping = 26, mass = 1 } = {}) {
|
|
this._signal = new Signal(value);
|
|
this._velocity = 0;
|
|
this._target = target !== undefined ? target : value;
|
|
this.stiffness = stiffness;
|
|
this.damping = damping;
|
|
this.mass = mass;
|
|
this._settled = true;
|
|
}
|
|
get value() { return this._signal.value; }
|
|
set value(v) {
|
|
// Assignment animates to new target (not instant set)
|
|
this.target = v;
|
|
}
|
|
get target() { return this._target; }
|
|
set target(t) {
|
|
this._target = t;
|
|
this._settled = false;
|
|
_activeSprings.add(this);
|
|
_startSpringLoop();
|
|
}
|
|
set(v) {
|
|
this._signal.value = v;
|
|
this._target = v;
|
|
this._velocity = 0;
|
|
this._settled = true;
|
|
_activeSprings.delete(this);
|
|
}
|
|
_step(dt) {
|
|
const pos = this._signal._value;
|
|
const vel = this._velocity;
|
|
const k = this.stiffness, d = this.damping, m = this.mass;
|
|
const accel = (p, v) => (-k * (p - this._target) - d * v) / m;
|
|
const k1v = accel(pos, vel), k1p = vel;
|
|
const k2v = accel(pos + k1p*dt/2, vel + k1v*dt/2), k2p = vel + k1v*dt/2;
|
|
const k3v = accel(pos + k2p*dt/2, vel + k2v*dt/2), k3p = vel + k2v*dt/2;
|
|
const k4v = accel(pos + k3p*dt, vel + k3v*dt), k4p = vel + k3v*dt;
|
|
this._velocity = vel + (dt/6)*(k1v + 2*k2v + 2*k3v + k4v);
|
|
this._signal.value = pos + (dt/6)*(k1p + 2*k2p + 2*k3p + k4p);
|
|
if (Math.abs(this._velocity) < 0.01 && Math.abs(this._signal._value - this._target) < 0.01) {
|
|
this._signal.value = this._target;
|
|
this._velocity = 0;
|
|
this._settled = true;
|
|
_activeSprings.delete(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
function _startSpringLoop() {
|
|
if (_rafId !== null) return;
|
|
_lastTime = performance.now();
|
|
_rafId = requestAnimationFrame(_springLoop);
|
|
}
|
|
|
|
function _springLoop(now) {
|
|
const dt = Math.min((now - _lastTime) / 1000, 0.064);
|
|
_lastTime = now;
|
|
batch(() => {
|
|
for (const s of _activeSprings) {
|
|
const steps = Math.ceil(dt / (1/120));
|
|
const subDt = dt / steps;
|
|
for (let i = 0; i < steps; i++) s._step(subDt);
|
|
}
|
|
});
|
|
if (_activeSprings.size > 0) _rafId = requestAnimationFrame(_springLoop);
|
|
else _rafId = null;
|
|
}
|
|
|
|
function spring(opts) { return new Spring(typeof opts === 'object' ? opts : { value: opts, target: opts }); }
|
|
|
|
// ── Constraint Solver ──
|
|
function constrain(element, prop, fn) {
|
|
return effect(() => {
|
|
const val = fn();
|
|
if (prop === 'width' || prop === 'height' || prop === 'left' || prop === 'top' ||
|
|
prop === 'right' || prop === 'bottom' || prop === 'maxWidth' || prop === 'minWidth' ||
|
|
prop === 'maxHeight' || prop === 'minHeight' || prop === 'fontSize' ||
|
|
prop === 'padding' || prop === 'margin' || prop === 'gap') {
|
|
element.style[prop] = typeof val === 'number' ? val + 'px' : val;
|
|
} else if (prop === 'opacity' || prop === 'scale') {
|
|
element.style[prop] = val;
|
|
} else {
|
|
element.style[prop] = val;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Viewport signal for responsive constraints
|
|
const _viewport = {
|
|
width: new Signal(window.innerWidth),
|
|
height: new Signal(window.innerHeight)
|
|
};
|
|
window.addEventListener('resize', () => {
|
|
_viewport.width.value = window.innerWidth;
|
|
_viewport.height.value = window.innerHeight;
|
|
});
|
|
|
|
// ── 2D Scene Rendering Engine ──
|
|
function scene(width, height) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width || 600;
|
|
canvas.height = height || 400;
|
|
canvas.style.borderRadius = '16px';
|
|
canvas.style.background = 'rgba(255,255,255,0.03)';
|
|
canvas.style.border = '1px solid rgba(255,255,255,0.08)';
|
|
canvas.style.display = 'block';
|
|
canvas.style.margin = '8px 0';
|
|
const ctx = canvas.getContext('2d');
|
|
const shapes = [];
|
|
let _dirty = false;
|
|
|
|
function _render() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
for (const s of shapes) s._draw(ctx);
|
|
}
|
|
|
|
function _scheduleRender() {
|
|
if (!_dirty) {
|
|
_dirty = true;
|
|
queueMicrotask(() => { _dirty = false; _render(); });
|
|
}
|
|
}
|
|
|
|
return { canvas, ctx, shapes, _render, _scheduleRender };
|
|
}
|
|
|
|
function circle(scn, opts) {
|
|
const shape = {
|
|
type: 'circle',
|
|
_draw(ctx) {
|
|
const x = typeof opts.x === 'object' && 'value' in opts.x ? opts.x.value : (typeof opts.x === 'function' ? opts.x() : opts.x);
|
|
const y = typeof opts.y === 'object' && 'value' in opts.y ? opts.y.value : (typeof opts.y === 'function' ? opts.y() : opts.y);
|
|
const r = typeof opts.r === 'object' && 'value' in opts.r ? opts.r.value : (typeof opts.r === 'function' ? opts.r() : (opts.r || 20));
|
|
const fill = opts.fill || '#8b5cf6';
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
|
|
// Gradient fill for nice look
|
|
const grad = ctx.createRadialGradient(x - r*0.3, y - r*0.3, r*0.1, x, y, r);
|
|
grad.addColorStop(0, '#a78bfa');
|
|
grad.addColorStop(1, fill);
|
|
ctx.fillStyle = grad;
|
|
ctx.fill();
|
|
|
|
// Glow
|
|
ctx.shadowColor = fill;
|
|
ctx.shadowBlur = 20;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
// Reactive: re-render when signal deps change
|
|
effect(() => {
|
|
shape._draw; // trigger reads
|
|
if (opts.x && typeof opts.x === 'object' && 'value' in opts.x) opts.x.value;
|
|
if (opts.y && typeof opts.y === 'object' && 'value' in opts.y) opts.y.value;
|
|
if (opts.r && typeof opts.r === 'object' && 'value' in opts.r) opts.r.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
function rect(scn, opts) {
|
|
const shape = {
|
|
type: 'rect',
|
|
_draw(ctx) {
|
|
const x = typeof opts.x === 'object' && 'value' in opts.x ? opts.x.value : (typeof opts.x === 'function' ? opts.x() : opts.x);
|
|
const y = typeof opts.y === 'object' && 'value' in opts.y ? opts.y.value : (typeof opts.y === 'function' ? opts.y() : opts.y);
|
|
const w = typeof opts.w === 'object' && 'value' in opts.w ? opts.w.value : (typeof opts.w === 'function' ? opts.w() : (opts.w || 40));
|
|
const h = typeof opts.h === 'object' && 'value' in opts.h ? opts.h.value : (typeof opts.h === 'function' ? opts.h() : (opts.h || 40));
|
|
const fill = opts.fill || '#6366f1';
|
|
const r = opts.radius || 8;
|
|
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, w, h, r);
|
|
ctx.fillStyle = fill;
|
|
ctx.fill();
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
effect(() => {
|
|
if (opts.x && typeof opts.x === 'object' && 'value' in opts.x) opts.x.value;
|
|
if (opts.y && typeof opts.y === 'object' && 'value' in opts.y) opts.y.value;
|
|
if (opts.w && typeof opts.w === 'object' && 'value' in opts.w) opts.w.value;
|
|
if (opts.h && typeof opts.h === 'object' && 'value' in opts.h) opts.h.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
function line(scn, opts) {
|
|
const shape = {
|
|
type: 'line',
|
|
_draw(ctx) {
|
|
const x1 = typeof opts.x1 === 'object' && 'value' in opts.x1 ? opts.x1.value : opts.x1;
|
|
const y1 = typeof opts.y1 === 'object' && 'value' in opts.y1 ? opts.y1.value : opts.y1;
|
|
const x2 = typeof opts.x2 === 'object' && 'value' in opts.x2 ? opts.x2.value : opts.x2;
|
|
const y2 = typeof opts.y2 === 'object' && 'value' in opts.y2 ? opts.y2.value : opts.y2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.strokeStyle = opts.stroke || 'rgba(139,92,246,0.3)';
|
|
ctx.lineWidth = opts.width || 2;
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
effect(() => {
|
|
if (opts.x1 && typeof opts.x1 === 'object' && 'value' in opts.x1) opts.x1.value;
|
|
if (opts.y1 && typeof opts.y1 === 'object' && 'value' in opts.y1) opts.y1.value;
|
|
if (opts.x2 && typeof opts.x2 === 'object' && 'value' in opts.x2) opts.x2.value;
|
|
if (opts.y2 && typeof opts.y2 === 'object' && 'value' in opts.y2) opts.y2.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
return { signal, derived, effect, batch, flush, onEvent, emit,
|
|
keyedList, route: _route, navigate, matchRoute,
|
|
resource, fetchJSON,
|
|
spring, constrain, viewport: _viewport,
|
|
scene, circle, rect, line,
|
|
Signal, Derived, Effect, Spring };
|
|
})();
|
|
"#;
|