Responsive layout
Passing a Viewport(maxWidth) to the renderer tells it how much horizontal space is available. Three things may happen,
all controlled by ResponsiveConfig:
| Mechanism | What it does | Controlled by |
|---|---|---|
| Direction flip | Opt-in. Below the threshold prefers vertical; at or above prefers horizontal | flipDirectionBelow (default None) |
| Spacing compression | Scales spacing and padding toward the viewport target | compressSpacing, minSpacingScale, maxSpacingScale |
| Container fit | Hybrid paint scales nodes and edges down to the column, never below a floor | fit (ContainerFit.ToWidth(0.5) or Off) |
Spacing compression and container fit are on by default. Direction is not. A viewport width changes spacing. It does
not turn an authored TD into LR. A sequence diagram does not flip either: a narrow viewport compresses
SequenceConfig.columnGap and rowPitch, then container fit scales the picture.
Direction flip
The default keeps the direction the author wrote. flipDirectionBelow = Some(640) opts in: below 640 the layout
prefers vertical flow, and at or above 640 it prefers horizontal.
| Author direction | Below threshold | At / above threshold |
|---|---|---|
LR | flips to TB | stays LR |
RL | flips to BT | stays RL |
TB / TD | stays vertical | flips to LR |
BT | stays BT | flips to RL |
The flip is a layout decision, not a CSS transform. The SVG dimensions and edge routes are recomputed for the new
direction. A static embed does not know the reader's window, so it does not flip. Pass a Viewport together with
flipDirectionBelow, or use diagramResponsive with a live width.
MermoidAscent.svgDiagram(chain)That is the authored layout: five nodes in a horizontal chain. The same chain with the flip opted in at 640px:
{
val flip = RenderConfig(responsive = ResponsiveConfig(flipDirectionBelow = Some(640)))
(for
wide <- sceneOf(chain, Some(Viewport(900)), flip)
narrow <- sceneOf(chain, Some(Viewport(400)), flip)
yield List(
s"Wide (${wide.width.toInt}×${wide.height.toInt}) direction: ${wide.direction}",
s"Narrow (${narrow.width.toInt}×${narrow.height.toInt}) direction: ${narrow.direction}",
s"Flipped: ${wide.direction != narrow.direction}",
).mkString("\n")).getOrElse("not a ranked diagram")
}Wide (864×165) direction: LR
Narrow (164×619) direction: TB
Flipped: trueWithout that opt-in, a flowchart TD laid out for a 900px column stays top to bottom:
{
val src =
Mermaid("""flowchart TD
A --> B
B --> C
""".stripMargin)
sceneOf(src, Some(Viewport(900))).fold("not a ranked diagram")(scene => s"Direction: ${scene.direction}")
}Direction: TDSpacing compression
When compressSpacing is true (default), the layout estimates how many nodes sit along the main axis and scales
spacing, padding, and parallel edge offset toward the viewport target. The scale is clamped between
minSpacingScale (default 0.45) and maxSpacingScale (default 1.75).
A diagram that needs 960px of width in a 320px viewport compresses aggressively but never below 45% of default spacing.
(for
big <- sceneOf(chain, Some(Viewport(1200)))
small <- sceneOf(chain, Some(Viewport(320)))
yield List(
s"Wide width: ${big.width.toInt}",
s"Narrow width: ${small.width.toInt}",
s"Compression ratio: ${(small.width / big.width).toString.take(4)}",
).mkString("\n")).getOrElse("not a ranked diagram")Wide width: 1014
Narrow width: 594
Compression ratio: 0.58Disable compression to keep the author's geometry intact regardless of viewport:
{
val compressed =
DiagramLayout.scene(chain.diagram, RenderConfig(), Some(Viewport(320))) // compressSpacing = true
val noCompress = DiagramLayout.scene(
chain.diagram,
RenderConfig(responsive = ResponsiveConfig(compressSpacing = false)),
Some(Viewport(320)),
)
List(
s"With compression: ${compressed.width.toInt}px wide",
s"Without compression: ${noCompress.width.toInt}px wide",
s"Rigid is wider: ${noCompress.width > compressed.width}",
).mkString("\n")
}With compression: 594px wide
Without compression: 814px wide
Rigid is wider: trueContainer fit
After layout, hybrid paint scales the scaler that holds the HTML nodes and the SVG edges together. The scale is
min(1, column / scene), and it never drops below the floor on ContainerFit.ToWidth (default 0.5). A scene that is
already narrower than the column stays at scale 1. Below the floor the root scrolls horizontally.
Scene.fitScale is the same policy for a known width (the interactive reflow path). ContainerFit.Off leaves
the scene at scale 1.
Spacing compression handles moderate overflows. A dense hub still exceeds its budget at minimum spacing, and that is when the floor matters:
{
val hub =
Mermaid("""flowchart LR
A --> B
A --> C
A --> D
A --> E
A --> F
A --> G
B --> H
C --> H
D --> H
E --> H
F --> H
G --> H
""".stripMargin)
val scene = DiagramLayout.scene(hub.diagram, RenderConfig(), Some(Viewport(320)))
List(
s"Scene width: ${scene.width.toInt}",
s"Viewport: 320",
s"fitScale(320): ${scene.fitScale(320)}",
s"Needs scaling: ${scene.fitScale(320) < 1.0}",
).mkString("\n")
}Scene width: 348
Viewport: 320
fitScale(320): 0.9195402298850575
Needs scaling: trueDisabling responsive entirely
Three knobs to turn off. Omitting the Viewport altogether is the simplest approach — layout runs unconstrained.
{
val constrained = DiagramLayout.scene(chain.diagram, RenderConfig(), Some(Viewport(320)))
val unconstrained = DiagramLayout.scene(chain.diagram, RenderConfig(), None)
List(
s"Constrained: ${constrained.width.toInt}×${constrained.height.toInt}",
s"Unconstrained: ${unconstrained.width.toInt}×${unconstrained.height.toInt}",
s"Unconstrained is wider: ${unconstrained.width > constrained.width}",
).mkString("\n")
}Constrained: 594×111
Unconstrained: 814×155
Unconstrained is wider: trueMeasuring text
Node and label widths use TextMeasure. The default, TextMeasure.estimate, counts characters
(LayoutConfig.charWidthEstimate) and ignores the font. The CLI, tests, SSR, and svg / svgDiagram use that
estimate, so those SVGs stay the same on the JVM and on Scala.js.
An ascent diagram (diagram, diagramInteractive, and the responsive variants) that runs where a browser document
exists, and whose caller left textMeasure empty, lays out from DomTextMeasure instead. A measure the caller passed
is kept. Static pages on this site use svgDiagram, so they stay on the estimate. The interactive examples are
remounted in the browser, and that mount is the one that measures.
RenderConfig(textMeasure = Some(DomTextMeasure()))
DomTextMeasure lives in mermoid-ascent and only compiles for Scala.js. It asks the canvas for measureText. Core
does not take a DOM dependency, and it does not call java.awt. When document is missing, the estimate stays in
place. The canvas helper's own fallback (half the font size per character) is only used when a host passes
DomTextMeasure and the document is gone.
In hybrid mode
mermoid-ascent recomputes the entire Scene on every width change: geometry, edge routes, and, when
flipDirectionBelow is set, direction. Selection state is preserved by node id, so clicking a node before reflow
keeps it selected after.
diagramFitting follows the container: the first paint is unconstrained, then a resize observer re-lays the scene out
at the container's content width. diagramResponsive is the same re-layout when the host already owns the width
source. See Interactive for live Narrow / Medium / Wide controls. Those buttons set 360px, 640px,
and 900px. Direction changes at 640px only when the diagram opts into flipDirectionBelow.
MermoidAscent.diagram(chain, RenderConfig(), Some(Viewport(640)))For external width sources (e.g., a ResizeObserver on the container), use MermoidAscent.diagramResponsive with a
Source[Double]. The built-in Narrow/Medium/Wide buttons are hidden when widthControls = WidthControls.Hidden.