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>
142 lines
4.5 KiB
Python
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)
|