Ilya 62780c66be feat(model): domain model, node library, and SVG symbols
Add UI-agnostic model layer: typed grouped properties (string/int/float/
bool/enum/color/catalogue item(s)/value list), catalogue registry, port
relations with color schemes and compatibility rules, ports, node/edge
models, and DiagramDocument with change signals and JSON round-trip.
Include a starter node library (source/tank/pump/valve/junction/consumer/
transformer) with matching SVG symbols, plus model+document tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:13:18 +02:00

142 lines
4.5 KiB
Python

"""EdgeModel: a connector between two ports, with style and properties."""
from __future__ import annotations
from enum import Enum
from typing import Optional
from .properties import PropertyBag
class EndpointDecoration(str, Enum):
"""Head/tail decorations drawn at an edge endpoint."""
NONE = "none"
ARROW = "arrow"
CIRCLE = "circle"
DIAMOND = "diamond"
BAR = "bar"
class LineStyle(str, Enum):
SOLID = "solid"
DASHED = "dashed"
DOTTED = "dotted"
DASH_DOT = "dash_dot"
class EdgeStyle:
"""Visual style of an edge."""
__slots__ = ("line_style", "width", "color", "tail", "head")
def __init__(
self,
*,
line_style: LineStyle = LineStyle.SOLID,
width: float = 2.0,
color: Optional[str] = None,
tail: EndpointDecoration = EndpointDecoration.NONE,
head: EndpointDecoration = EndpointDecoration.ARROW,
) -> None:
self.line_style = LineStyle(line_style)
self.width = float(width)
self.color = color # None => derive from relation
self.tail = EndpointDecoration(tail)
self.head = EndpointDecoration(head)
def to_dict(self) -> dict:
return {
"line_style": self.line_style.value,
"width": self.width,
"color": self.color,
"tail": self.tail.value,
"head": self.head.value,
}
@classmethod
def from_dict(cls, data: dict) -> "EdgeStyle":
return cls(
line_style=LineStyle(data.get("line_style", "solid")),
width=data.get("width", 2.0),
color=data.get("color"),
tail=EndpointDecoration(data.get("tail", "none")),
head=EndpointDecoration(data.get("head", "arrow")),
)
def clone(self) -> "EdgeStyle":
return EdgeStyle.from_dict(self.to_dict())
class EdgeModel:
"""A connector between (source_node.source_port) and (target_node.target_port).
``relation`` records the media the edge carries (usually inherited from the
connected ports) and drives default coloring. ``waypoints`` optionally pins
intermediate routing points; when empty the router computes an orthogonal path.
"""
def __init__(
self,
edge_id: str,
source_node: str,
source_port: str,
target_node: str,
target_port: str,
*,
relation: str = "",
style: Optional[EdgeStyle] = None,
properties: Optional[PropertyBag] = None,
waypoints: Optional[list[tuple[float, float]]] = None,
) -> None:
self.edge_id = edge_id
self.source_node = source_node
self.source_port = source_port
self.target_node = target_node
self.target_port = target_port
self.relation = relation
self.style = style if style is not None else EdgeStyle()
self.properties: PropertyBag = properties if properties is not None else PropertyBag()
self.waypoints: list[tuple[float, float]] = list(waypoints or [])
# Populated by the flow solver (mock calculation); not persisted.
self.flow: float = 0.0 # signed magnitude along source->target
self.flow_direction: int = 0 # +1 s->t, -1 t->s, 0 none
@property
def title(self) -> str:
return self.properties.value("title", "") or ""
def endpoints(self) -> tuple[tuple[str, str], tuple[str, str]]:
return (self.source_node, self.source_port), (self.target_node, self.target_port)
def to_dict(self) -> dict:
return {
"id": self.edge_id,
"source_node": self.source_node,
"source_port": self.source_port,
"target_node": self.target_node,
"target_port": self.target_port,
"relation": self.relation,
"style": self.style.to_dict(),
"properties": self.properties.to_dict(),
"waypoints": [list(w) for w in self.waypoints],
}
@classmethod
def from_dict(cls, data: dict) -> "EdgeModel":
return cls(
data["id"],
data["source_node"],
data["source_port"],
data["target_node"],
data["target_port"],
relation=data.get("relation", ""),
style=EdgeStyle.from_dict(data.get("style", {})),
properties=PropertyBag.from_dict(data.get("properties", {})),
waypoints=[tuple(w) for w in data.get("waypoints", [])],
)
def clone(self, new_id: str) -> "EdgeModel":
d = self.to_dict()
d["id"] = new_id
return EdgeModel.from_dict(d)