1use std::collections::HashMap;
13
14use ezu_style as spec;
15
16use crate::node::Node;
17
18#[derive(Debug, Clone)]
20pub struct Connection {
21 pub port: String,
23 pub src: String,
25}
26
27pub struct BuiltNode {
31 pub node: Box<dyn Node>,
32 pub connections: Vec<Connection>,
33}
34
35pub struct FactoryCtx<'a> {
38 pub params: &'a indexmap::IndexMap<String, spec::ParamDecl>,
39 pub sources: &'a indexmap::IndexMap<String, spec::SourceDecl>,
40}
41
42#[derive(Debug, thiserror::Error)]
43pub enum FactoryError {
44 #[error("missing required field `{0}`")]
45 MissingField(String),
46 #[error("field `{field}` has wrong type: {msg}")]
47 BadField { field: String, msg: String },
48 #[error("unknown param reference `${0}`")]
49 UnknownParam(String),
50 #[error("unknown asset reference `@{0}`")]
51 UnknownAsset(String),
52 #[error("{0}")]
53 Custom(String),
54}
55
56pub trait NodeFactory: Send + Sync {
62 fn op_name(&self) -> &'static str;
64
65 fn build(
66 &self,
67 fields: &serde_json::Map<String, serde_json::Value>,
68 ctx: &FactoryCtx<'_>,
69 ) -> Result<BuiltNode, FactoryError>;
70
71 fn schema(&self) -> serde_json::Value {
78 serde_json::json!({})
79 }
80}
81
82pub struct StaticOp(pub &'static dyn NodeFactory);
86
87inventory::collect!(StaticOp);
88
89#[macro_export]
98macro_rules! submit_node {
99 ($factory:ident) => {
100 $crate::inventory::submit! {
101 $crate::StaticOp(&$factory)
102 }
103 };
104}
105
106#[derive(Default)]
108pub struct NodeRegistry {
109 ops: HashMap<&'static str, &'static dyn NodeFactory>,
110}
111
112impl NodeRegistry {
113 pub fn new() -> Self {
114 Self::default()
115 }
116
117 pub fn from_inventory() -> Self {
120 let mut r = Self::default();
121 for StaticOp(f) in inventory::iter::<StaticOp> {
122 r.register_static(*f);
123 }
124 r
125 }
126
127 pub fn register(&mut self, factory: impl NodeFactory + 'static) {
131 self.register_static(Box::leak(Box::new(factory)));
132 }
133
134 pub fn register_static(&mut self, factory: &'static dyn NodeFactory) {
137 self.ops.insert(factory.op_name(), factory);
138 }
139
140 pub fn get(&self, op_name: &str) -> Option<&dyn NodeFactory> {
141 self.ops.get(op_name).copied()
142 }
143
144 pub fn op_names(&self) -> Vec<&'static str> {
146 let mut names: Vec<_> = self.ops.keys().copied().collect();
147 names.sort_unstable();
148 names
149 }
150
151 pub fn document_schema(&self) -> serde_json::Value {
157 use serde_json::{json, Value};
158 let mut variants: Vec<Value> = Vec::with_capacity(self.ops.len());
159 for op in self.op_names() {
160 let factory = self
161 .ops
162 .get(op)
163 .expect("op_names yields keys present in self.ops");
164 let mut schema = factory.schema();
165 if !schema.is_object() {
166 schema = json!({});
167 }
168 let obj = schema
169 .as_object_mut()
170 .expect("schema was just normalized to an object");
171 obj.entry("type").or_insert_with(|| json!("object"));
172 let props = obj
174 .entry("properties")
175 .or_insert_with(|| json!({}))
176 .as_object_mut()
177 .expect("`properties` was just inserted as a JSON object");
178 props.insert(
179 "op".to_string(),
180 json!({ "const": op, "description": format!("Selects the `{op}` operation.") }),
181 );
182 let required = obj
184 .entry("required")
185 .or_insert_with(|| json!([]))
186 .as_array_mut()
187 .expect("`required` was just inserted as a JSON array");
188 if !required.iter().any(|v| v.as_str() == Some("op")) {
189 required.insert(0, json!("op"));
190 }
191 obj.insert("title".to_string(), json!(format!("op: {op}")));
192 variants.push(schema);
193 }
194
195 variants.push(json!({
199 "type": "object",
200 "title": "op: func",
201 "description": "Call a user-defined function declared in the document's `functions` block.",
202 "required": ["op", "fn"],
203 "properties": {
204 "op": { "const": "func" },
205 "fn": { "type": "string", "description": "Name of the function to call." }
206 },
207 "additionalProperties": true
208 }));
209
210 let func_kinds = json!([
211 "features",
212 "raster",
213 "sprite",
214 "brush",
215 "scalar",
216 "scalar-field"
217 ]);
218
219 json!({
220 "$schema": "https://json-schema.org/draft/2020-12/schema",
221 "title": "Ezu Style Spec",
222 "type": "object",
223 "required": ["name", "nodes", "output"],
224 "properties": {
225 "name": { "type": "string" },
226 "version": { "type": "string" },
227 "tile-size": { "type": "integer", "minimum": 1, "default": 512,
228 "description": "Rendered tile edge in px. Every `*-px` field is measured against it, so it decides what a pixel is worth in ground units. Defaults to 512, MapLibre's vector-tile convention." },
229 "pad": { "type": "integer", "minimum": 0 },
230 "attribution": {
231 "type": "string",
232 "description": "Attribution for the style itself (HTML allowed). Merged with per-source and upstream attributions."
233 },
234 "params": {
235 "type": "object",
236 "additionalProperties": {
237 "type": "object",
238 "required": ["type", "default"],
239 "properties": {
240 "type": { "enum": ["color", "number", "bool"] },
241 "default": {},
242 "min": { "type": "number" },
243 "max": { "type": "number" },
244 "description": { "type": "string" }
245 }
246 }
247 },
248 "legend": {
249 "type": "object",
250 "description": "What the map's symbols mean. Never rendered into a tile — hosts read it to draw a legend beside the map.",
251 "properties": {
252 "title": { "type": "string", "description": "Heading — usually what is being mapped." },
253 "note": { "type": "string", "description": "Prose below the entries: sources, classification method, what the map leaves out." },
254 "entries": {
255 "type": "array",
256 "description": "Entries in reading order.",
257 "items": {
258 "type": "object",
259 "required": ["label", "from"],
260 "properties": {
261 "label": { "type": "string", "description": "What the reader sees." },
262 "from": { "type": "string", "description": "The node that draws this symbol; must produce a Raster." },
263 "properties": { "type": "object", "description": "Feature properties selecting this entry's case." },
264 "note": { "type": "string" },
265 "min-zoom": { "type": "integer", "minimum": 0 },
266 "max-zoom": { "type": "integer", "minimum": 0 },
267 "geometry": {
268 "enum": ["all", "polygon", "line", "point"],
269 "description": "Geometry the swatch's stand-in feature carries. Defaults to all three; name one when a geometry op between the source and this node would otherwise make the node draw twice."
270 }
271 }
272 }
273 }
274 }
275 },
276 "functions": {
277 "type": "object",
278 "description": "User-defined functions: reusable node subgraphs called via `op: func`.",
279 "additionalProperties": {
280 "type": "object",
281 "required": ["output", "output-kind", "nodes"],
282 "properties": {
283 "description": { "type": "string" },
284 "inputs": {
285 "type": "object",
286 "additionalProperties": {
287 "type": "object",
288 "required": ["kind"],
289 "properties": {
290 "kind": { "enum": func_kinds.clone() },
291 "default": {},
292 "description": { "type": "string" }
293 }
294 }
295 },
296 "output": {
297 "type": "string",
298 "description": "Body node (with or without `@`) the call produces."
299 },
300 "output-kind": { "enum": func_kinds },
301 "nodes": { "$ref": "#/properties/nodes" }
302 }
303 }
304 },
305 "sources": {
306 "type": "object",
307 "additionalProperties": {
308 "oneOf": [
309 {
310 "type": "object",
311 "required": ["type", "src"],
312 "properties": {
313 "type": { "enum": ["brush", "image"] },
314 "src": { "type": "string" },
315 "attribution": { "type": "string" }
316 }
317 },
318 {
319 "type": "object",
320 "required": ["type", "url"],
321 "properties": {
322 "type": { "enum": ["mvt", "pmtiles"] },
323 "url": { "type": "string" },
324 "attribution": { "type": "string", "description": "Explicit attribution; inherits upstream TileJSON / PMTiles metadata when absent." }
325 }
326 },
327 {
328 "type": "object",
329 "required": ["type", "url", "encoding"],
330 "properties": {
331 "type": { "const": "dem" },
332 "url": { "type": "string", "description": "XYZ template or TileJSON URL." },
333 "encoding": { "enum": ["terrarium", "mapbox-rgb"] },
334 "tile-size": { "type": "integer", "minimum": 1, "default": 256,
335 "description": "Edge of the source's own tiles in px — not the document's canvas, and defaulting to 256 rather than 512. Terrarium pyramids are usually 256, Mapbox-RGB ones often 512." },
336 "max-zoom": { "type": "integer", "minimum": 0, "description": "Deepest zoom the source serves. Past it, tiles are overzoomed from the covering ancestor." },
337 "neighbor-fetch": { "type": "boolean", "default": true },
338 "elevation-offset": { "type": "number" },
339 "on-missing": { "enum": ["empty", "upsample", "error"], "default": "empty", "description": "404 within zoom range: zero elevation, upsample a parent, or fail the tile." },
340 "attribution": { "type": "string" }
341 }
342 },
343 {
344 "type": "object",
345 "required": ["type", "url"],
346 "properties": {
347 "type": { "const": "raster" },
348 "url": { "type": "string", "description": "XYZ template, TileJSON URL, or PMTiles archive (`.pmtiles`). PNG/WebP/JPEG tiles." },
349 "max-zoom": { "type": "integer", "minimum": 0 },
350 "neighbor-fetch": { "type": "boolean" },
351 "on-missing": { "enum": ["empty", "upsample", "error"], "default": "empty", "description": "404 within zoom range: transparent pixels, upsample a parent, or fail the tile." },
352 "attribution": { "type": "string" }
353 }
354 }
355 ]
356 }
357 },
358 "nodes": {
359 "type": "object",
360 "additionalProperties": { "oneOf": variants }
361 },
362 "output": {
363 "type": "string",
364 "description": "Node id of the final raster (with or without `@`)."
365 }
366 }
367 })
368 }
369}
370
371pub mod schema_frag {
374 use serde_json::{json, Value};
375
376 pub fn node_ref() -> Value {
378 json!({
379 "type": "string",
380 "pattern": "^@?[A-Za-z_][A-Za-z0-9_-]*$",
381 "description": "Reference to another node (`@name`)."
382 })
383 }
384
385 pub fn asset_ref() -> Value {
387 json!({
388 "type": "string",
389 "description": "Asset reference (`@name`) or literal path."
390 })
391 }
392
393 pub fn color() -> Value {
396 json!({
397 "type": "string",
398 "pattern": "^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|[$@][A-Za-z_][A-Za-z0-9_-]*)$",
399 "description": "sRGB hex color, `$param` reference, or `@node` scalar port."
400 })
401 }
402
403 pub fn nested_color() -> Value {
407 json!({
408 "type": "string",
409 "pattern": "^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|\\$[A-Za-z_][A-Za-z0-9_-]*)$",
410 "description": "sRGB hex color or `$param` reference."
411 })
412 }
413
414 pub fn nested_number(literal: Value) -> Value {
418 json!({
419 "oneOf": [
420 literal,
421 {
422 "type": "string",
423 "pattern": "^\\$[A-Za-z_][A-Za-z0-9_-]*$",
424 "description": "`$param` reference."
425 }
426 ]
427 })
428 }
429
430 pub fn unit_number() -> Value {
432 in_number(json!({ "type": "number", "minimum": 0.0, "maximum": 1.0 }))
433 }
434
435 pub fn px_number() -> Value {
437 in_number(json!({ "type": "number", "minimum": 0.0 }))
438 }
439
440 pub fn number() -> Value {
442 in_number(json!({ "type": "number" }))
443 }
444
445 pub fn in_number(literal: Value) -> Value {
449 json!({
450 "oneOf": [
451 literal,
452 {
453 "type": "string",
454 "pattern": "^[$@][A-Za-z_][A-Za-z0-9_-]*$",
455 "description": "`$param` reference or `@node` scalar port."
456 }
457 ]
458 })
459 }
460}
461
462pub fn take_input_ref(
467 fields: &serde_json::Map<String, serde_json::Value>,
468 name: &str,
469) -> Result<String, FactoryError> {
470 let v = fields
471 .get(name)
472 .ok_or_else(|| FactoryError::MissingField(name.to_string()))?;
473 let s = v.as_str().ok_or_else(|| FactoryError::BadField {
474 field: name.to_string(),
475 msg: "expected string node reference".into(),
476 })?;
477 match spec::FieldRef::classify(s) {
478 spec::FieldRef::Node(id) => Ok(id.to_string()),
479 _ => Err(FactoryError::BadField {
480 field: name.to_string(),
481 msg: format!("expected `@node-ref`, got `{s}`"),
482 }),
483 }
484}
485
486pub fn take_optional_input_ref(
489 fields: &serde_json::Map<String, serde_json::Value>,
490 name: &str,
491) -> Result<Option<String>, FactoryError> {
492 match fields.get(name) {
493 None => Ok(None),
494 Some(v) if v.is_null() => Ok(None),
495 Some(_) => Ok(Some(take_input_ref(fields, name)?)),
496 }
497}