diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py
index 20cd34b58..d1dde45a5 100644
--- a/src/datajoint/diagram.py
+++ b/src/datajoint/diagram.py
@@ -47,6 +47,107 @@
logger = logging.getLogger(__name__.split(".")[0])
+# Structural node attributes per tier — shape, sizing, and whether the box has
+# rounded corners. These are theme-independent; only the colors change with the
+# theme. `_scale` matches the historical 1.2 scaling factor for fonts and boxes.
+_scale = 1.2
+_TIER_STRUCTURE = {
+ None: dict(shape="circle", fontsize=round(_scale * 8), size=0.4 * _scale, fixed=False, rounded=False),
+ Manual: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True),
+ Lookup: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True),
+ Computed: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False),
+ Imported: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False),
+ # A part inherits its master's tier and so has no tier-shape of its own;
+ # historically it was drawn boxless. It nonetheless gets a neutral subtle box
+ # (not a tier shape) so that on the platform each part is a clickable target
+ # that opens the table. Keep the box for that reason.
+ Part: dict(shape="box", fontsize=round(_scale * 8), size=0.1 * _scale, fixed=False, rounded=True),
+ "collapsed": dict(shape="box3d", fontsize=round(_scale * 10), size=0.5 * _scale, fixed=False, rounded=False),
+}
+
+# Color themes (#1532). Each tier gets a (fill, stroke, text) triple. Edge colors
+# share a single alpha so a renamed (amber) edge sits at the same visual density
+# as ordinary edges, differing only in hue.
+_DIAGRAM_THEMES = {
+ "light": dict(
+ bg=None,
+ palette={
+ None: ("#FFFDE7", "#C9BC5B", "#6B6420"),
+ Manual: ("#E7F3EC", "#2F7D5B", "#1B5138"),
+ Lookup: ("#F2F4F7", "#A9B1BD", "#495261"),
+ Computed: ("#FBEAEC", "#B23A48", "#7C2430"),
+ Imported: ("#E2ECFA", "#2A5FA5", "#123A6D"),
+ Part: ("#FFFFFF", "#9AA6B8", "#46536B"),
+ "collapsed": ("#EDEEF0", "#808890", "#404040"),
+ },
+ edge="#3A424F",
+ edge_renamed="#C77D3A",
+ edge_alpha="9E",
+ schema_cluster=("gray", "gray"),
+ entity_fill="#F3F5F8",
+ ),
+ "dark": dict(
+ bg="#161A21",
+ palette={
+ None: ("#3A3620", "#C9BC5B", "#EBE3A0"),
+ Manual: ("#16281F", "#4FA97F", "#BCE6CF"),
+ Lookup: ("#242832", "#8A93A1", "#C9CFD9"),
+ Computed: ("#331A1F", "#D0687A", "#F3C2CB"),
+ Imported: ("#152538", "#5E92D6", "#C3DAF6"),
+ Part: ("#1E232C", "#7B879B", "#C4CCDB"),
+ "collapsed": ("#242730", "#8890A0", "#C7CDD6"),
+ },
+ edge="#AEB6C2",
+ edge_renamed="#D68C4A",
+ edge_alpha="C0",
+ schema_cluster=("#606875", "#8A93A1"),
+ entity_fill="#2A313D",
+ ),
+}
+
+
+def _adaptive_style_block() -> str:
+ """
+ Build a ``"
+
+
class Diagram(nx.MultiDiGraph): # noqa: C901
"""
Schema diagram as a directed acyclic graph (DAG).
@@ -92,7 +193,7 @@ class Diagram(nx.MultiDiGraph): # noqa: C901
Layout direction is controlled via ``dj.config.display.diagram_direction``
(default ``"TB"``). Use ``dj.config.override()`` to change temporarily::
- with dj.config.override(display_diagram_direction="LR"):
+ with dj.config.override(display__diagram_direction="LR"):
dj.Diagram(schema).draw()
"""
@@ -558,7 +659,7 @@ def __getitem__(self, key):
>>> trace["my_schema.Session"].to_dicts() # string index → FreeTable
"""
# Non-trace diagrams: defer to networkx adjacency lookup so existing
- # `diagram[node_name]` patterns (used in diagram algebra, ERD tests)
+ # `diagram[node_name]` patterns (used in diagram algebra, diagram tests)
# keep working.
if getattr(self, "_mode", None) != "trace":
return super().__getitem__(key)
@@ -1095,15 +1196,17 @@ def _make_graph(self) -> nx.MultiDiGraph:
nx.MultiDiGraph
Graph with nodes relabeled to class names.
"""
- # mark "distinguished" tables, i.e. those that introduce new primary key
- # attributes
+ # Mark tables that introduce a new schema dimension, i.e. that add a
+ # primary-key attribute of their own beyond what they inherit through
+ # foreign keys. These are drawn with an underlined label. ("Schema
+ # dimension" / "axis" is the documented term for such a table.)
# Filter nodes_to_show to only include nodes that exist in the graph
valid_nodes = self.nodes_to_show.intersection(set(self.nodes()))
for name in valid_nodes:
foreign_attributes = set(
attr for p in self.in_edges(name, data=True) for attr in p[2]["attr_map"] if p[2]["primary"]
)
- self.nodes[name]["distinguished"] = (
+ self.nodes[name]["introduces_dimension"] = (
"primary_key" in self.nodes[name] and foreign_attributes < self.nodes[name]["primary_key"]
)
# construct subgraph and rename nodes to class names. A MultiDiGraph is
@@ -1339,7 +1442,7 @@ def _encapsulate_node_names(graph: nx.MultiDiGraph) -> None:
copy=False,
)
- def make_dot(self):
+ def make_dot(self, theme=None):
"""
Generate a pydot graph object.
@@ -1410,72 +1513,23 @@ def make_dot(self):
if data.get("collapsed") and data.get("schema_name"):
schema_map[node] = data["schema_name"]
- scale = 1.2 # scaling factor for fonts and boxes
- label_props = { # http://matplotlib.org/examples/color/named_colors.html
- None: dict(
- shape="circle",
- color="#FFFF0040",
- fontcolor="yellow",
- fontsize=round(scale * 8),
- size=0.4 * scale,
- fixed=False,
- ),
- Manual: dict(
- shape="box",
- color="#00FF0030",
- fontcolor="darkgreen",
- fontsize=round(scale * 10),
- size=0.4 * scale,
- fixed=False,
- ),
- Lookup: dict(
- shape="plaintext",
- color="#00000020",
- fontcolor="black",
- fontsize=round(scale * 8),
- size=0.4 * scale,
- fixed=False,
- ),
- Computed: dict(
- shape="ellipse",
- color="#FF000020",
- fontcolor="#7F0000A0",
- fontsize=round(scale * 10),
- size=0.4 * scale,
- fixed=False,
- ),
- Imported: dict(
- shape="ellipse",
- color="#00007F40",
- fontcolor="#00007FA0",
- fontsize=round(scale * 10),
- size=0.4 * scale,
- fixed=False,
- ),
- Part: dict(
- shape="plaintext",
- color="#00000000",
- fontcolor="black",
- fontsize=round(scale * 8),
- size=0.1 * scale,
- fixed=False,
- ),
- "collapsed": dict(
- shape="box3d",
- color="#80808060",
- fontcolor="#404040",
- fontsize=round(scale * 10),
- size=0.5 * scale,
- fixed=False,
- ),
- }
- # Build node_props, handling collapsed nodes specially
+ # Select the color theme (#1532). Structure (shape/size/rounded) is
+ # theme-independent; only the fill/stroke/text colors change. `theme`
+ # (an explicit argument) overrides the configured default; "auto" is
+ # rendered in light colors here and made adaptive at the SVG layer, so
+ # it maps to the light palette.
+ theme_name = theme or self._connection._config.display.diagram_theme
+ theme = _DIAGRAM_THEMES.get(theme_name if theme_name != "auto" else "light", _DIAGRAM_THEMES["light"])
+ palette = theme["palette"]
+
+ # Build node_props by merging the structural attributes for each tier
+ # with the theme's (fill, stroke, text) colors. Collapsed nodes use the
+ # "collapsed" entry.
node_props = {}
for node, d in graph.nodes(data=True):
- if d.get("collapsed"):
- node_props[node] = label_props["collapsed"]
- else:
- node_props[node] = label_props[d["node_type"]]
+ tier = "collapsed" if d.get("collapsed") else d["node_type"]
+ fill, stroke, text = palette[tier]
+ node_props[node] = dict(_TIER_STRUCTURE[tier], fill=fill, stroke=stroke, fontcolor=text)
# A renamed (aliased) FK is drawn as a distinctly-colored edge (there
# is no longer an intermediate "alias" node); describe the column
@@ -1494,6 +1548,44 @@ def make_dot(self):
self._encapsulate_edge_attributes(graph)
dot = nx.drawing.nx_pydot.to_pydot(graph)
dot.set_rankdir(direction)
+ if theme["bg"]:
+ dot.set_bgcolor(theme["bg"])
+
+ # Master↔part grouping (#1532): map each part to its master, and record
+ # which parts depend on a sibling part so an intra-group chain can
+ # descend rather than share a rank. Nodes may be named either by class
+ # ("Master.Part") when a context resolves them, or by raw table name
+ # ("schema.master__part") otherwise, so the master is found by trying
+ # both suffix conventions against the actual node keys. Everything here
+ # is keyed by the stripped node name (matching the loops below).
+ key_by_stripped = {k.strip('"'): k for k in graph.nodes()}
+
+ def _master_of(part_stripped):
+ candidates = set()
+ if "." in part_stripped:
+ candidates.add(part_stripped.rsplit(".", 1)[0]) # class: Master.Part -> Master
+ if "__" in part_stripped:
+ candidates.add(part_stripped.rsplit("__", 1)[0]) # table: ...master__part -> ...master
+ for candidate in candidates:
+ if candidate in key_by_stripped:
+ return candidate
+ return None
+
+ part_master = {}
+ for gname, gdata in graph.nodes(data=True):
+ if gdata.get("node_type") is Part:
+ part_stripped = gname.strip('"')
+ master_stripped = _master_of(part_stripped)
+ if master_stripped is not None:
+ part_master[part_stripped] = master_stripped
+ part_names = set(part_master)
+ depends_on_sibling = set()
+ for part_stripped, master_stripped in part_master.items():
+ for pred in graph.predecessors(key_by_stripped[part_stripped]):
+ pred_stripped = pred.strip('"')
+ if pred_stripped in part_names and part_master.get(pred_stripped) == master_stripped:
+ depends_on_sibling.add(part_stripped)
+
for node in dot.get_nodes():
node.set_shape("circle")
name = node.get_name().strip('"')
@@ -1501,10 +1593,11 @@ def make_dot(self):
node.set_fontsize(props["fontsize"])
node.set_fontcolor(props["fontcolor"])
node.set_shape(props["shape"])
- node.set_fontname("arial")
+ node.set_fontname("Helvetica")
node.set_fixedsize("shape" if props["fixed"] else False)
node.set_width(props["size"])
node.set_height(props["size"])
+ node.set_margin("0.11,0.06") # generous label padding (inches)
# Handle collapsed nodes specially
node_data = graph.nodes.get(f'"{name}"', {})
@@ -1525,14 +1618,20 @@ def make_dot(self):
node.set_tooltip("
".join(description))
# Strip module prefix from label if it matches the cluster label
display_name = name
- schema_name = schema_map.get(name)
- if schema_name and "." in name:
- cluster_label = cluster_labels.get(schema_name)
- if cluster_label and name.startswith(cluster_label + "."):
- display_name = name[len(cluster_label) + 1 :]
- node.set_label("<" + display_name + ">" if node.get("distinguished") == "True" else display_name)
- node.set_color(props["color"])
- node.set_style("filled")
+ if name in part_names:
+ # The entity cluster carries master membership, so a part
+ # shows only its own name (`Scan`, not `Acquisition.Scan`).
+ display_name = name.rsplit(".", 1)[-1]
+ else:
+ schema_name = schema_map.get(name)
+ if schema_name and "." in name:
+ cluster_label = cluster_labels.get(schema_name)
+ if cluster_label and name.startswith(cluster_label + "."):
+ display_name = name[len(cluster_label) + 1 :]
+ node.set_label("<" + display_name + ">" if node.get("introduces_dimension") == "True" else display_name)
+ node.set_fillcolor(props["fill"])
+ node.set_color(props["stroke"])
+ node.set_style("rounded,filled" if props.get("rounded") else "filled")
for edge in dot.get_edges():
# see https://graphviz.org/doc/info/attrs.html
@@ -1540,19 +1639,28 @@ def make_dot(self):
# pydot edge — to_pydot stringifies the edge data, so booleans arrive
# as "True"/"False". This is parallel-edge-safe: each FK between the
# same pair of tables is its own pydot edge.
- src = edge.get_source()
- dest = edge.get_destination()
primary = str(edge.get("primary")) == "True"
multi = str(edge.get("multi")) == "True"
aliased = str(edge.get("aliased")) == "True"
- # Renamed FK → distinct color; others → the usual translucent black.
- edge.set_color("#FF8800" if aliased else "#00000040")
+ # Renamed FK → a distinct, desaturated amber consistent with the
+ # modernized palette (#1532); others → a translucent slate. Both
+ # share the theme's edge alpha so the amber sits at the same visual
+ # density as ordinary edges, differing only in hue.
+ base = theme["edge_renamed"] if aliased else theme["edge"]
+ edge.set_color(base + theme["edge_alpha"])
edge.set_style("solid" if primary else "dashed")
- dest_node_type = graph.nodes[dest].get("node_type")
- master_part = dest_node_type is Part and dest.startswith(src + ".")
- edge.set_weight(3 if master_part else 1)
- edge.set_arrowhead("none")
+ # Line weight encodes cardinality, and only cardinality. `multi` is
+ # True when the child has primary-key attributes beyond those this
+ # foreign key contributes — whether newly declared or inherited from
+ # another foreign key — i.e. a one-to-many dependency, drawn thin.
+ # When the foreign key constitutes the child's *entire* primary key
+ # the dependency is 1:1, drawn thick. Master-part is NOT a weight: a
+ # part almost always adds a key attribute, so its edge is thin under
+ # this same rule. penwidth is the visible thickness; the layout
+ # `weight` hint follows the same predicate so the two never diverge.
edge.set_penwidth(0.75 if multi else 2)
+ edge.set_weight(1 if multi else 3)
+ edge.set_arrowhead("none")
# Group nodes into schema clusters (always on)
if schema_map:
@@ -1568,27 +1676,87 @@ def make_dot(self):
schemas[schema_name] = []
schemas[schema_name].append(node)
- # Create clusters for each schema
- # Use Python module name if 1:1 mapping, otherwise database schema name
+ # Create clusters for each schema. Within a schema, a master and its
+ # parts are enclosed together in a nested, unlabeled entity cluster
+ # (#1532); master and its parts share a rank so horizontal reads as
+ # derivation and vertical as containment, except a part that depends
+ # on a sibling part, which is left off the rank so the intra-group
+ # chain descends.
for schema_name, nodes in schemas.items():
label = cluster_labels.get(schema_name, schema_name)
+ sc_color, sc_fontcolor = theme["schema_cluster"]
cluster = pydot.Cluster(
f"cluster_{schema_name}",
label=label,
- style="dashed",
- color="gray",
- fontcolor="gray",
+ labelloc="t",
+ labeljust="r", # schema name in the top-right corner of the cluster
+ style="rounded,dashed",
+ color=sc_color,
+ fontcolor=sc_fontcolor,
+ fontname="Helvetica", # schema label in a sans font, not Graphviz's Times default
)
- for node in nodes:
- cluster.add_node(node)
+ node_by_name = {n.get_name().strip('"'): n for n in nodes}
+ # masters in this schema that have at least one part present
+ masters_here = {}
+ for pn in part_names:
+ mn = part_master[pn]
+ if pn in node_by_name and mn in node_by_name:
+ masters_here.setdefault(mn, []).append(pn)
+
+ grouped = set()
+ for master_name, parts in masters_here.items():
+ # Subtle rounded shaded background (no dashed frame) — the
+ # entity grouping should read quietly, not compete with the
+ # schema box.
+ entity = pydot.Cluster(
+ "cluster_entity_" + master_name.replace(".", "_"),
+ label="",
+ style="rounded,filled",
+ fillcolor=theme["entity_fill"],
+ color=theme["entity_fill"],
+ )
+ entity.add_node(node_by_name[master_name])
+ grouped.add(master_name)
+ same_rank = [node_by_name[master_name].get_name()]
+ for pn in parts:
+ entity.add_node(node_by_name[pn])
+ grouped.add(pn)
+ if pn not in depends_on_sibling:
+ same_rank.append(node_by_name[pn].get_name())
+ if len(same_rank) > 1:
+ rank = pydot.Subgraph(rank="same")
+ for nm in same_rank:
+ rank.add_node(pydot.Node(nm))
+ entity.add_subgraph(rank)
+ cluster.add_subgraph(entity)
+
+ for name, node in node_by_name.items():
+ if name not in grouped:
+ cluster.add_node(node)
dot.add_subgraph(cluster)
return dot
+ def svg_string(self) -> str:
+ """
+ Render the diagram to an SVG string, honoring the color theme.
+
+ For ``theme="auto"`` the diagram is rendered in light colors and a
+ ``prefers-color-scheme`` style block is injected so a single image
+ adapts to the viewer's light or dark mode. Other themes render directly.
+ """
+ theme_name = self._connection._config.display.diagram_theme
+ if theme_name == "auto":
+ svg = self.make_dot(theme="light").create_svg().decode()
+ # Insert the adaptive style block right after the opening