From 6ce7f634d4dc3d0aafd84e9f4ccad726bf391c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Wed, 12 Aug 2026 05:18:01 +0200 Subject: [PATCH 1/7] docs: drop See also blocks, weave cross-links into prose --- docs/composition/alignment.md | 70 ++++++++++++++ docs/composition/distribution.md | 55 +++++++++++ docs/composition/grid.md | 69 ++++++++++++++ docs/composition/groups.md | 57 ++++++++++++ docs/composition/overlays.md | 60 ++++++++++++ docs/composition/stacks.md | 80 ++++++++++++++++ docs/connections.md | 113 +++++++++++++++++++++++ docs/geometry/constraints.md | 42 +++++++++ docs/geometry/fit.md | 71 ++++++++++++++ docs/geometry/overview.md | 73 +++++++++++++++ docs/getting-started.md | 98 ++++++++++++++++++++ docs/images/align-bottom-center.svg | 18 ++++ docs/images/align-bottom-left.svg | 18 ++++ docs/images/align-bottom-right.svg | 18 ++++ docs/images/align-cross-center.svg | 18 ++++ docs/images/align-cross-end.svg | 18 ++++ docs/images/align-cross-start.svg | 18 ++++ docs/images/align-cross-stretch.svg | 18 ++++ docs/images/align-middle-center.svg | 18 ++++ docs/images/align-middle-left.svg | 18 ++++ docs/images/align-middle-right.svg | 18 ++++ docs/images/align-top-center.svg | 18 ++++ docs/images/align-top-left.svg | 18 ++++ docs/images/align-top-right.svg | 18 ++++ docs/images/aspectframe.svg | 17 ++++ docs/images/badge-end.svg | 20 ++++ docs/images/badge-start.svg | 20 ++++ docs/images/bounds-union.svg | 19 ++++ docs/images/connection-l.svg | 19 ++++ docs/images/connection-straight.svg | 19 ++++ docs/images/connection-z.svg | 19 ++++ docs/images/connectionlabel-above.svg | 20 ++++ docs/images/connectionlabel-below.svg | 20 ++++ docs/images/connectionlabel-centered.svg | 20 ++++ docs/images/constraints-loose.svg | 17 ++++ docs/images/constraints-tight.svg | 17 ++++ docs/images/distribute-center.svg | 18 ++++ docs/images/distribute-end.svg | 18 ++++ docs/images/distribute-space-around.svg | 18 ++++ docs/images/distribute-space-between.svg | 18 ++++ docs/images/distribute-space-evenly.svg | 18 ++++ docs/images/distribute-start.svg | 18 ++++ docs/images/edgeband.svg | 17 ++++ docs/images/figures.css | 9 ++ docs/images/fit-contain.svg | 17 ++++ docs/images/fit-cover.svg | 17 ++++ docs/images/fit-fill.svg | 17 ++++ docs/images/fit-scale-down.svg | 17 ++++ docs/images/grid-2x2.svg | 19 ++++ docs/images/grid-3-columns.svg | 18 ++++ docs/images/grid-column-span.svg | 18 ++++ docs/images/grid-dashed-slot.svg | 19 ++++ docs/images/grid-row-span.svg | 18 ++++ docs/images/group-bounds.svg | 19 ++++ docs/images/group.svg | 18 ++++ docs/images/inlinegroup-content.svg | 18 ++++ docs/images/inlinegroup-equal.svg | 18 ++++ docs/images/insets-padding.svg | 17 ++++ docs/images/legend-horizontal.svg | 21 +++++ docs/images/legend-vertical.svg | 21 +++++ docs/images/overlay-badge.svg | 17 ++++ docs/images/overlay-center.svg | 17 ++++ docs/images/spacer.svg | 17 ++++ docs/images/stack-column.svg | 18 ++++ docs/images/stack-gap-loose.svg | 18 ++++ docs/images/stack-gap-none.svg | 18 ++++ docs/images/stack-row.svg | 18 ++++ docs/images/trackgroup-horizontal.svg | 18 ++++ docs/images/trackgroup-vertical.svg | 18 ++++ docs/text.md | 82 ++++++++++++++++ docs/tracks-bands-legends.md | 84 +++++++++++++++++ 71 files changed, 2000 insertions(+) create mode 100644 docs/composition/alignment.md create mode 100644 docs/composition/distribution.md create mode 100644 docs/composition/grid.md create mode 100644 docs/composition/groups.md create mode 100644 docs/composition/overlays.md create mode 100644 docs/composition/stacks.md create mode 100644 docs/connections.md create mode 100644 docs/geometry/constraints.md create mode 100644 docs/geometry/fit.md create mode 100644 docs/geometry/overview.md create mode 100644 docs/getting-started.md create mode 100644 docs/images/align-bottom-center.svg create mode 100644 docs/images/align-bottom-left.svg create mode 100644 docs/images/align-bottom-right.svg create mode 100644 docs/images/align-cross-center.svg create mode 100644 docs/images/align-cross-end.svg create mode 100644 docs/images/align-cross-start.svg create mode 100644 docs/images/align-cross-stretch.svg create mode 100644 docs/images/align-middle-center.svg create mode 100644 docs/images/align-middle-left.svg create mode 100644 docs/images/align-middle-right.svg create mode 100644 docs/images/align-top-center.svg create mode 100644 docs/images/align-top-left.svg create mode 100644 docs/images/align-top-right.svg create mode 100644 docs/images/aspectframe.svg create mode 100644 docs/images/badge-end.svg create mode 100644 docs/images/badge-start.svg create mode 100644 docs/images/bounds-union.svg create mode 100644 docs/images/connection-l.svg create mode 100644 docs/images/connection-straight.svg create mode 100644 docs/images/connection-z.svg create mode 100644 docs/images/connectionlabel-above.svg create mode 100644 docs/images/connectionlabel-below.svg create mode 100644 docs/images/connectionlabel-centered.svg create mode 100644 docs/images/constraints-loose.svg create mode 100644 docs/images/constraints-tight.svg create mode 100644 docs/images/distribute-center.svg create mode 100644 docs/images/distribute-end.svg create mode 100644 docs/images/distribute-space-around.svg create mode 100644 docs/images/distribute-space-between.svg create mode 100644 docs/images/distribute-space-evenly.svg create mode 100644 docs/images/distribute-start.svg create mode 100644 docs/images/edgeband.svg create mode 100644 docs/images/figures.css create mode 100644 docs/images/fit-contain.svg create mode 100644 docs/images/fit-cover.svg create mode 100644 docs/images/fit-fill.svg create mode 100644 docs/images/fit-scale-down.svg create mode 100644 docs/images/grid-2x2.svg create mode 100644 docs/images/grid-3-columns.svg create mode 100644 docs/images/grid-column-span.svg create mode 100644 docs/images/grid-dashed-slot.svg create mode 100644 docs/images/grid-row-span.svg create mode 100644 docs/images/group-bounds.svg create mode 100644 docs/images/group.svg create mode 100644 docs/images/inlinegroup-content.svg create mode 100644 docs/images/inlinegroup-equal.svg create mode 100644 docs/images/insets-padding.svg create mode 100644 docs/images/legend-horizontal.svg create mode 100644 docs/images/legend-vertical.svg create mode 100644 docs/images/overlay-badge.svg create mode 100644 docs/images/overlay-center.svg create mode 100644 docs/images/spacer.svg create mode 100644 docs/images/stack-column.svg create mode 100644 docs/images/stack-gap-loose.svg create mode 100644 docs/images/stack-gap-none.svg create mode 100644 docs/images/stack-row.svg create mode 100644 docs/images/trackgroup-horizontal.svg create mode 100644 docs/images/trackgroup-vertical.svg create mode 100644 docs/text.md create mode 100644 docs/tracks-bands-legends.md diff --git a/docs/composition/alignment.md b/docs/composition/alignment.md new file mode 100644 index 0000000..39e92ff --- /dev/null +++ b/docs/composition/alignment.md @@ -0,0 +1,70 @@ +--- +order: 60 +--- +# Alignment + +Alignment answers one question: where does a child sit inside the space it was given, when it does not fill that space. + +`Alignment` has four cases, and they mean the same thing on both axes. + +| Case | Horizontal | Vertical | +|---|---|---| +| `Start` | left | top | +| `Center` | centered | centered | +| `End` | right | bottom | +| `Stretch` | fill the width | fill the height | + +## The Nine Positions + +`Group` and `Grid` take a horizontal and a vertical alignment, which together give the nine positions below. + +
+
Content packed against the top left corner
Start, Start
+
Content centered horizontally against the top edge
Center, Start
+
Content packed against the top right corner
End, Start
+
Content against the left edge, centered vertically
Start, Center
+
Content centered on both axes
Center, Center
+
Content against the right edge, centered vertically
End, Center
+
Content packed against the bottom left corner
Start, End
+
Content centered horizontally against the bottom edge
Center, End
+
Content packed against the bottom right corner
End, End
+
+ +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Element\Group; + +Group::of('root') + ->align(Alignment::End, Alignment::Start) + ->add($content); +``` + +`Grid::align()` takes the same pair and applies it to every slot, and `GridBuilder::add()` accepts `alignX` and `alignY` to override it for one item. + +## Cross-Axis Alignment In A Stack + +A stack aligns its children on the cross axis only: the main axis is governed by gap and [distribution](distribution.md). `alignItems()` takes a single `Alignment`. + +
+
Boxes of different heights aligned on their top edges
Start
+
Boxes of different heights centered on a common axis
Center
+
Boxes of different heights aligned on their bottom edges
End
+
Boxes stretched to a common height
Stretch
+
+ +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Element\Stack; + +Stack::row('legend') + ->gap(8) + ->alignItems(Alignment::Stretch) + ->add($swatch) + ->add($label); +``` + +`Stretch` only has an effect on a child that accepts being resized. A `Frame::fixed()` keeps its size and falls back to `Start`. + +## Alignment In Text + +`TextBlock::align()` takes the same enum, with one difference worth knowing: `Stretch` behaves like `Start`. Layout does not justify ](../text, so there is nothing to stretch. See [Text](../text.md). diff --git a/docs/composition/distribution.md b/docs/composition/distribution.md new file mode 100644 index 0000000..30daa3c --- /dev/null +++ b/docs/composition/distribution.md @@ -0,0 +1,55 @@ +--- +order: 70 +--- +# Distribution + +Distribution decides what happens to the space a stack has left over on its main axis once every child has been measured and every gap applied. + +It only matters when there is leftover space. A stack whose children fill the axis exactly, or that contains a flexible child such as `Frame::stretch()` or a `Spacer`, has nothing left to distribute. + +## The Six Modes + +
+
Three boxes packed at the start of the axis, free space after them
Start
+
Three boxes packed in the middle, free space on both sides
Center
+
Three boxes packed at the end of the axis, free space before them
End
+
Three boxes with equal gaps between them and none at the edges
SpaceBetween
+
Three boxes each surrounded by equal space, half-sized at the edges
SpaceAround
+
Three boxes with equal space between them and at both edges
SpaceEvenly
+
+ +```php +use Atelier\Layout\Distribution; +use Atelier\Layout\Element\Stack; + +Stack::row('cards') + ->gap(8) + ->distribute(Distribution::SpaceBetween) + ->add($a) + ->add($b) + ->add($c); +``` + +## Reading The Three Space Modes + +The three packing modes are obvious. The three spacing modes differ only in what they do at the edges, and that is the whole distinction: + +- `SpaceBetween` puts nothing at the edges. With `n` children it creates `n - 1` equal gaps. +- `SpaceAround` gives every child an equal share of space on both sides, so the edge space is half of the space between two children. +- `SpaceEvenly` makes every space equal, edges included. With `n` children it creates `n + 1` identical gaps. + +`gap()` is added on top in every mode. A stack with both a gap and `SpaceBetween` keeps the gap as a minimum and shares only what remains. + +## Distribution Or Spacer + +Two ways exist to push things apart, and they are not interchangeable. + +Distribution is a property of the container: it treats every child the same way. A `Spacer` is a child: it takes the space at one precise position in the list. Use distribution when the rhythm is uniform, a spacer when only one seam should open up. + +```php +// Uniform: three cards spread across the row. +Stack::row('cards')->distribute(Distribution::SpaceEvenly); + +// Positional: logo on the left, avatar on the right, nothing in between. +Stack::row('bar')->add($logo)->add(new Spacer('push'))->add($avatar); +``` diff --git a/docs/composition/grid.md b/docs/composition/grid.md new file mode 100644 index 0000000..6b9609a --- /dev/null +++ b/docs/composition/grid.md @@ -0,0 +1,69 @@ +--- +order: 50 +--- +# Grid + +`Grid` is track-based placement on two axes. You declare column and row tracks, then add children that occupy one or more slots. + +
+
A grid of two columns and two rows, each cell filled
two columns, two rows
+
A grid of three equal columns in a single row
three columns
+
+ +```php +use Atelier\Layout\Element\Grid; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Grid\TrackSize; +use Atelier\Layout\LayoutContext; + +$grid = Grid::tracks('items', [TrackSize::fr(), TrackSize::fr()]) + ->rows([TrackSize::fixed(40), TrackSize::fr()]) + ->gap(12) + ->add($header, columnSpan: 2) + ->add($left) + ->add($right); + +$solved = $grid->solve(new LayoutContext(), Rect::fromSize(400, 240)); +``` + +`Grid::columns('id', 3)` is the short form when every column is an equal fraction. + +## Track Sizes + +| Constructor | Meaning | +|---|---| +| `TrackSize::fixed(float $px)` | an exact number of pixels | +| `TrackSize::fr(float $fraction = 1.0)` | a share of the space left after fixed and auto tracks | +| `TrackSize::auto()` | sized to the content it holds | + +Fixed tracks are resolved first, auto tracks next, and fraction tracks share whatever remains in proportion to their fraction. A grid narrower than the sum of its fixed tracks does not shrink them. + +## Spans + +`add()` takes `columnSpan` and `rowSpan`. A spanning child covers the slots plus the gaps between them, so a title spanning two columns is wider than the two columns it covers by exactly one gap. + +
+
A grid where one cell stretches across two columns
columnSpan: 2
+
A grid where one cell stretches down across two rows
rowSpan: 2
+
+ +## Empty Slots + +Children fill slots in the order they are added, left to right then top to bottom. There is no explicit slot addressing: to leave a hole, add a child that draws nothing, or reorder the tracks so the hole falls at the end. + +A grid with one slot left empty, outlined with a dashed border + +## Alignment Inside Slots + +`align()` sets the alignment used by every slot; `add()` overrides it per item through `alignX` and `alignY`. + +```php +use Atelier\Layout\Alignment; + +Grid::tracks('items', [TrackSize::fr(), TrackSize::fr()]) + ->align(Alignment::Center, Alignment::Center) + ->add($left) + ->add($right, alignX: Alignment::End); +``` + +A child aligned `Stretch` fills its slot. A `Frame::fixed()` ignores `Stretch` and keeps its declared size. diff --git a/docs/composition/groups.md b/docs/composition/groups.md new file mode 100644 index 0000000..393edf0 --- /dev/null +++ b/docs/composition/groups.md @@ -0,0 +1,57 @@ +--- +order: 80 +--- +# Groups + +`Group` places children in the same local region under one shared alignment policy. Unlike a stack it does not lay children out in sequence, and unlike an overlay it does not snap them to anchors: every child gets the same box and the same alignment. + +Several boxes sharing one region under a common alignment + +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Element\Group; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\LayoutContext; +use Atelier\Layout\Value\InsetSpec; + +$group = Group::of('badges') + ->padding(InsetSpec::px(8)) + ->align(Alignment::End, Alignment::Start) + ->add($primary) + ->add($secondary); + +$solved = $group->solve(new LayoutContext(), Rect::fromSize(160, 96)); +``` + +Use it for badges, grouped labels, and overlays that are not anchor-specific. + +## Reading Solved Frames + +Every solved tree returns a `PlacedNode`. Ids are the bridge between layout and renderers: you name a node when you build it, and you ask for it by that name afterwards. + +```php +$solved->frameOf('primary'); // the Rect, or null +$solved->find('primary'); // the PlacedNode, or null +``` + +Nothing downstream needs to know how the frame was computed. A renderer that receives `frameOf('primary')` draws a rectangle; whether it came from a grid slot, a stack child, or a group is not its concern. + +## Bounds Around Solved Children + +`Bounds` computes the rectangle that contains a set of rectangles. It is a pure value helper, not a node: give it frames you already solved. + +A dashed rectangle enclosing several solved boxes + +```php +use Atelier\Layout\Geometry\Bounds; +use Atelier\Layout\Geometry\Insets; + +$box = Bounds::of($a, $b, $c); +$padded = Bounds::expand($box, Insets::all(12)); +``` + +`Bounds::fromRects(iterable $rects)` takes any iterable and returns `null` for an empty one, which is the form to use when the set is built at runtime and may be empty. + +Two overlapping rectangles and the single rectangle that contains both + +Use it to frame a cluster, to compute a background plate behind a group of nodes, or to size a viewBox around everything that was solved. diff --git a/docs/composition/overlays.md b/docs/composition/overlays.md new file mode 100644 index 0000000..3daba2e --- /dev/null +++ b/docs/composition/overlays.md @@ -0,0 +1,60 @@ +--- +order: 90 +--- +# Overlays And Badges + +`Overlay` places children in the same outer rectangle by snapping anchors. Each child declares which of its own anchors meets which anchor of the host, plus an optional offset. + +
+
A small box centered on top of a larger one
centered on the host
+
A small box snapped to the top right corner of a larger one, overhanging it
a corner badge
+
+ +```php +use Atelier\Layout\Anchor; +use Atelier\Layout\Element\Overlay; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\LayoutContext; + +$overlay = Overlay::of('card') + ->add($body) + ->add($badge, subject: Anchor::Center, target: Anchor::TopRight); + +$solved = $overlay->solve(new LayoutContext(), Rect::fromSize(200, 120)); +``` + +`subject` is the anchor on the child, `target` the anchor on the host. Making them equal pins the child inside the host; making them opposite lets it overhang, which is how a notification badge sits half outside its button. + +## The Nine Anchors + +`Anchor` names the same nine positions on any rectangle: `TopLeft`, `TopCenter`, `TopRight`, `CenterLeft`, `Center`, `CenterRight`, `BottomLeft`, `BottomCenter`, `BottomRight`. + +`offsetX` and `offsetY` shift the child after snapping, in that order, and are applied unconditionally: a negative offset moves it back toward the host. + +## Alignment Or Anchoring + +Both position a child in a box, and choosing between them is a question of what stays true when sizes change. + +[Alignment](alignment.md) keeps the child inside the box. An `End`-aligned child touches the right edge and never crosses it. Anchoring computes the meeting point of two anchors, so the child can sit astride the edge or fully outside. Use alignment for content, anchoring for decoration attached to content. + +## Badges On A Connection + +The same idea applies to the ends of a connection, where the host is not a rectangle but the start or end of a path. + +
+
A small marker placed at the start of a connector
EndpointStart
+
A small marker placed at the end of a connector
EndpointEnd
+
+ +```php +use Atelier\Layout\Connection\ConnectionEndpointBadge; +use Atelier\Layout\Connection\ConnectionEndpointBadgePlacement; +use Atelier\Layout\Geometry\Size; + +$badge = ConnectionEndpointBadge::for($connection, ConnectionEndpointBadgePlacement::EndpointEnd) + ->size(new Size(16, 16)) + ->avoid($index) + ->place(); +``` + +`avoid()` takes a `RectIndex` and skips occupied placements deterministically before falling back to the requested one. Cardinality markers on an ER relation and multiplicities on a class association are the usual consumers. diff --git a/docs/composition/stacks.md b/docs/composition/stacks.md new file mode 100644 index 0000000..a1be7a9 --- /dev/null +++ b/docs/composition/stacks.md @@ -0,0 +1,80 @@ +--- +order: 40 +--- +# Stacks And Spacing + +`Stack` is one-dimensional flow: children are placed along one axis, separated by a fixed gap, inside optional padding. + +
+
Three boxes placed side by side along a horizontal axis
Stack::row()
+
Three boxes placed one under another along a vertical axis
Stack::column()
+
+ +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Element\Frame; +use Atelier\Layout\Element\Stack; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\LayoutContext; +use Atelier\Layout\Value\InsetSpec; + +$row = Stack::row('toolbar') + ->gap(8) + ->padding(InsetSpec::px(12)) + ->alignItems(Alignment::Center) + ->add(Frame::fixed('back', 24, 24)) + ->add(Frame::stretch('title')) + ->add(Frame::fixed('menu', 24, 24)); + +$solved = $row->solve(new LayoutContext(), Rect::fromSize(320, 48)); +``` + +Use it for toolbars, legends, vertical sections, and repeated rows. + +## Gap + +`gap()` is the space between adjacent children. It is never applied before the first child or after the last one, so a stack of one child has no gap at all. + +
+
Three boxes touching, with no space between them
gap(0)
+
Three boxes separated by wide even spaces
a larger gap()
+
+ +Gap is fixed. To spread children across leftover space instead, see [Distribution](distribution.md). + +## Padding + +`padding()` insets the content rectangle before children are placed. It is available on every container, not just stacks. + +An outer rectangle with an inner rectangle inset on all four sides + +```php +use Atelier\Layout\Value\InsetSpec; + +InsetSpec::zero(); +InsetSpec::px(12); +InsetSpec::percent(4); +``` + +`InsetSpec::percent()` resolves against the container size, so a `4%` padding on a `200x400` box is not the same number of pixels on each axis. `resolve(Size $size)` returns the concrete `Insets`. + +## Spacer + +`Spacer` consumes flexible space inside a stack. Use it when a layout needs a flexible gap rather than a fixed-size empty box. + +Two boxes pushed to opposite ends of a row by a flexible gap between them + +```php +use Atelier\Layout\Element\Spacer; + +Stack::row('bar') + ->add(Frame::fixed('logo', 32, 32)) + ->add(new Spacer('push')) + ->add(Frame::fixed('avatar', 32, 32)); +``` + +A spacer takes part in flex distribution exactly like `Frame::stretch()`, but carries no identity of its own in the rendered output. + +## Baseline Alignment + +`alignToBaseline()` aligns children on the first text baseline instead of the box edge. It only changes anything when the children expose a baseline, which today means ](../text blocks. diff --git a/docs/connections.md b/docs/connections.md new file mode 100644 index 0000000..0db10c5 --- /dev/null +++ b/docs/connections.md @@ -0,0 +1,113 @@ +--- +order: 40 +--- +# Links Between Boxes + +Link primitives describe connection geometry without producing SVG path data. + +In this package, linking boxes means: + +1. choose where a connection leaves one rectangle; +2. choose where it enters another rectangle; +3. compute the intermediate points of the line; +4. expose helper points for labels and arrowheads. + +It is the geometry behind any arrow, relation, or connector drawn between two boxes. + +## Ports + +`Port::on($rect, PortSide::Right)` returns a point on a rectangle edge plus its side. + +Sides: + +- `Top` +- `Right` +- `Bottom` +- `Left` + +## Orthogonal Links + +The connector chooses sides from the relative centers of the two rectangles and returns one of three shapes: a straight segment when the boxes line up, an L when they do not, or a Z when the approach has to cross back. + +
+
Two aligned boxes joined by a single straight segment
straight
+
Two offset boxes joined by a connector with one right-angle turn
one turn
+
Two boxes joined by a connector with two right-angle turns
two turns
+
+ +```php +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Geometry\RectIndex; +use Atelier\Layout\Geometry\Size; +use Atelier\Layout\Connection\OrthogonalConnector; +use Atelier\Layout\Connection\ConnectionLabel; +use Atelier\Layout\Connection\ConnectionLabelPlacement; + +$from = new Rect(20, 20, 80, 40); +$to = new Rect(180, 60, 90, 40); + +$connection = (new OrthogonalConnector())->connect($from, $to); + +$connection->points; +$connection->segments; +$connection->labelPoint; +$connection->tipTangent; +$connection->isStraight(); + +$label = ConnectionLabel::for($connection) + ->size(new Size(20, 10)) + ->avoid(RectIndex::from(['node.a' => $from])) + ->placement(ConnectionLabelPlacement::Centered) + ->place(); +``` + +For the example above, the connection leaves the right side of `$from`, enters the left side of `$to`, and may return points like: + +```text +100,40 -> 140,40 -> 140,80 -> 180,80 +``` + +A renderer can turn those points into SVG path data: + +```text +M 100 40 L 140 40 L 140 80 L 180 80 +``` + +`labelPoint` is the suggested midpoint for an edge label. `tipTangent` is the final direction vector, useful for drawing an arrowhead at the target. + +`segments` exposes immutable straight pieces with stable indices and axes, so consumers can reason about the first leg, the middle turn, or the final approach without scanning the point list again. + +## Labels + +`ConnectionLabel` turns a connection plus a measured label size into a deterministic label frame, anchor point, and chosen segment index. It is still renderer-neutral. + +
+
A label frame sitting above a connector segment
Above
+
A label frame straddling a connector segment
Centered
+
A label frame sitting below a connector segment
Below
+
+ +`ConnectionLabelPlacement` also offers `Start` and `End` for labels pinned near one extremity rather than the middle. + +If you already have solved rectangles, `ConnectionLabel::avoid()` skips occupied placements deterministically before falling back to the requested placement. See [Geometry](geometry/overview.md) for the `RectIndex` it takes. + +## What Links Between Boxes Do Not Do + +This package does not currently solve: + +- graph ranking +- obstacle avoidance +- edge bundling +- self-loop semantics +- label collision avoidance +- arrowhead drawing + +Those are still consumer responsibilities. A consumer takes the returned points and turns them into whatever its renderer draws. + +## Demo + +```bash +php examples/connections-demo.php +``` + +The demo prints the source rect, target rect, connection points, label point, and arrowhead tangent for left-to-right, top-to-bottom, and right-to-left cases. diff --git a/docs/geometry/constraints.md b/docs/geometry/constraints.md new file mode 100644 index 0000000..8f7697e --- /dev/null +++ b/docs/geometry/constraints.md @@ -0,0 +1,42 @@ +--- +order: 100 +--- +# Constraints + +`BoxConstraints` is what a parent tells a child before asking it how big it wants to be. It travels down the tree during measurement; sizes travel back up. + +
+
A box forced to exactly the size of its container
tight()
+
A box smaller than its container, free to keep its own size
unconstrained()
+
+ +```php +use Atelier\Layout\Constraint\BoxConstraints; +use Atelier\Layout\Geometry\Size; + +BoxConstraints::tight(200, 48)->constrain(new Size(320, 40)); // 200 x 48 +BoxConstraints::unconstrained()->constrain(new Size(320, 40)); // 320 x 40 +``` + +A tight constraint admits exactly one size and the child has no say. An unconstrained one lets the child report its intrinsic size. `constrain()` is the operation that turns a wish into an allowed size. + +## Where They Come From + +You rarely build constraints by hand. Containers derive them: a grid slot constrains its child to the slot, a stack constrains on the cross axis and lets the main axis flow, a fixed frame reports the same size whatever it is asked. + +Constraints matter when you implement `LayoutNodeInterface` yourself. `measure()` receives them and must return an `IntrinsicSize` that respects them; `solve()` receives the final `Rect` and places content in it. + +```php +public function measure(LayoutContext $context, BoxConstraints $constraints): IntrinsicSize; +public function solve(LayoutContext $context, Rect $rect): PlacedNode; +``` + +The split is deliberate. Measurement may run several times as a parent explores sizes; solving runs once, on the answer. Keep `measure()` free of side effects. + +## Flexible Children + +`FlexibleLayoutNodeInterface` adds `flex(): float`. A node that reports a non-zero flex asks for a share of the leftover main-axis space instead of its measured size. `Frame::stretch()` and `Spacer` both implement it. + +Flex is resolved after fixed children are measured, which is why a stretch child never squeezes a fixed one: it only ever receives what is left. + +[Grid](../composition/grid.md) track sizes are the other place where fixed and flexible meet, and [Distribution](../composition/distribution.md) decides what happens to the leftover space when no child is flexible at all. diff --git a/docs/geometry/fit.md b/docs/geometry/fit.md new file mode 100644 index 0000000..da07ee5 --- /dev/null +++ b/docs/geometry/fit.md @@ -0,0 +1,71 @@ +--- +order: 30 +--- +# Fit + +`Fit` places a source of known size inside a target rectangle and returns the rectangle it should occupy. It is the geometry behind image placement, viewBox positioning, and fixed-aspect tiles. + +
+
A wide source scaled down until it fits entirely inside the target, leaving bands
Contain
+
A wide source scaled up until it covers the target, overflowing on two sides
Cover
+
A source stretched on both axes to match the target exactly
Fill
+
A small source kept at its natural size inside a larger target
ScaleDown
+
+ +```php +use Atelier\Layout\Anchor; +use Atelier\Layout\Fit\Fit; +use Atelier\Layout\Fit\FitMode; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Geometry\Size; + +$placed = Fit::rect( + new Size(1600, 900), + Rect::fromSize(320, 320), + FitMode::Contain, + Anchor::Center, +); +``` + +## The Five Modes + +| Mode | Aspect ratio | Result | +|---|---|---| +| `Contain` | kept | the largest size that fits entirely inside the target | +| `Cover` | kept | the smallest size that covers the target entirely | +| `Fill` | broken | exactly the target, stretched on both axes | +| `None` | kept | the source size, untouched | +| `ScaleDown` | kept | `None` when the source already fits, `Contain` otherwise | + +`Contain` never overflows and usually leaves empty bands. `Cover` never leaves bands and usually overflows. Which one you want depends on whether the empty space or the lost content is the lesser problem. + +`ScaleDown` is the one to reach for when a source may be either smaller or larger than its target and should never be enlarged: thumbnails, logos, icons of mixed provenance. + +## Anchoring The Result + +The anchor decides where the fitted rectangle sits when it does not fill the target, and which part survives when it overflows. + +```php +Fit::rect($source, $target, FitMode::Cover, Anchor::TopCenter); +``` + +With `Cover`, a `TopCenter` anchor keeps the top of the source visible and crops the bottom. That is usually what portraits want and what landscapes do not. + +## Aspect Frames + +`AspectFrame` is the reusable form of the same question: reserve a rectangle of a given ratio inside available space. + +A rectangle of fixed proportion centered inside a larger available area + +```php +use Atelier\Layout\Aspect\AspectFrame; +use Atelier\Layout\Value\InsetSpec; + +$frame = AspectFrame::of('media', 16, 9) + ->padding(InsetSpec::px(8)) + ->fitMode(FitMode::Contain) + ->anchor(Anchor::Center) + ->place($available); +``` + +Use it for media slots, chart canvases, and any tile whose proportion is part of the design rather than a consequence of its content. When the ratio should come from the tracks instead of from the tile, size it with [Grid](../composition/grid.md). diff --git a/docs/geometry/overview.md b/docs/geometry/overview.md new file mode 100644 index 0000000..156df38 --- /dev/null +++ b/docs/geometry/overview.md @@ -0,0 +1,73 @@ +--- +order: 20 +--- +# Geometry + +Geometry values are immutable and renderer-neutral. They do not render, mutate, or depend on SVG. + +## Value Types + +- `Point`: x/y coordinate. +- `Size`: width/height. +- `Rect`: x/y/width/height, anchors, inset. +- `Insets`: top/right/bottom/left. +- `Bounds`: union and expansion of rectangles. [Groups](../composition/groups.md) computes one around solved frames. +- `RectIndex`: immutable rectangle collision and occupancy queries. + +## Frame Model + +`BoxModel` turns an outer rectangle into a content rectangle: + +```php +use Atelier\Layout\Geometry\BoxModel; +use Atelier\Layout\Geometry\Insets; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Geometry\StrokePlacement; + +$content = (new BoxModel( + outer: new Rect(0, 0, 200, 400), + padding: new Insets(16, 8, 16, 8), + strokeWidth: 10, + strokePlacement: StrokePlacement::Inside, +))->contentRect(); +``` + +This is the answer to fixed-canvas questions like: + +> In a `200x400` canvas with `4%` padding and a fixed `10px` contained stroke, what space remains? + +Stroke placement is part of the arithmetic because it changes the answer. An inside stroke eats content space, an outside one does not, and a centered one eats half. + +## Circle Safe Areas + +`Circle` exposes safe areas for labels: + +```php +use Atelier\Layout\Geometry\Circle; +use Atelier\Layout\Geometry\Point; + +$safe = (new Circle(new Point(100, 200), 80)) + ->safeSquare(Insets::all(8), strokeWidth: 10, strokePlacement: StrokePlacement::Inside); +``` + +Consumers can lay text into that safe rectangle without knowing how the final renderer draws circles. Venn sets and pie labels use it. + +## Rect Index + +`RectIndex` answers small collision questions over solved frames: + +```php +use Atelier\Layout\Geometry\RectIndex; + +$index = RectIndex::from([ + 'node.a' => $frameA, + 'node.b' => $frameB, +]); + +$hits = $index->intersecting($candidateLabel); +$isFree = $index->isFree($candidateLabel, ignore: ['edge.ab.label']); +``` + +Edge-touching does not count as an intersection. That keeps dense scenes from rejecting labels that merely line up with an edge. + +`ignore` exists because the most common query is "is this position free, apart from the thing I am about to move there". diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..c79d9ac --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,98 @@ +--- +order: 10 +--- +# Getting Started + +`atelier/layout` solves geometry before rendering. You describe boxes, tracks, text, padding, and constraints; it returns frames that any renderer can consume. + +## Install + +```bash +composer require atelier/layout +``` + +## Solve A Grid + +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Element\Frame; +use Atelier\Layout\Element\Grid; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Grid\TrackSize; +use Atelier\Layout\LayoutContext; + +$layout = Grid::tracks('row', [ + TrackSize::fixed(48), + TrackSize::fr(), + TrackSize::fixed(48), +]) + ->gap(8) + ->align(Alignment::Center, Alignment::Center) + ->add(Frame::fixed('left', 32, 32)) + ->add(Frame::stretch('middle'), alignX: Alignment::Stretch) + ->add(Frame::fixed('right', 32, 32)); + +$result = $layout->solve(new LayoutContext(), Rect::fromSize(240, 48)); + +$result->frameOf('middle'); +``` + +## Solve Text + +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Element\TextBlock; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\LayoutContext; + +$text = TextBlock::of('caption', 'Wrapped bottom label', 12) + ->align(Alignment::Center, Alignment::End) + ->layout(new LayoutContext(), new Rect(0, 0, 90, 48)); + +$text->lines; +$text->hasOverflow(); +``` + +## Use Geometry Helpers + +```php +use Atelier\Layout\Geometry\Bounds; +use Atelier\Layout\Geometry\Insets; +use Atelier\Layout\Connection\OrthogonalConnector; + +$bounds = Bounds::expand(Bounds::of($a, $b, $c), Insets::all(12)); +$connection = (new OrthogonalConnector())->connect($a, $b); +``` + +Geometry helpers are pure values: they do not render, mutate, or depend on SVG. + +## Where To Go Next + +| If you need to | Read | +|---|---| +| place things along one axis | [Stacks And Spacing](composition/stacks.md) | +| place things on two axes | [Grid](composition/grid.md) | +| position a child inside its box | [Alignment](composition/alignment.md) | +| share leftover space | [Distribution](composition/distribution.md) | +| attach a badge to a corner | [Overlays And Badges](composition/overlays.md) | +| fit an image or a ratio | [Fit](geometry/fit.md) | +| draw an arrow between two boxes | [Connections](connections.md) | +| wrap and measure a label | [Text And Inline Runs](text.md) | +| build lanes, an axis strip, or a key | [Tracks, Bands And Legends](tracks-bands-legends.md) | + +## Demos + +The scripts in `examples/` are executable, small, and print concrete numbers, which makes solved geometry easy to inspect. + +```bash +php examples/composition-demo.php +php examples/connections-demo.php +php examples/composition-gallery.php +``` + +`composition-demo.php` combines a fixed canvas, an outer margin, grid padding, row and column tracks, a spanning title slot, bottom-aligned text, bounds around two solved items, and a link between them. + +`connections-demo.php` isolates links between boxes, printing the source rect, the target rect, the connection points, the label point, and the final tangent for arrowheads. + +`composition-gallery.php` writes a dashboard, an overlay board, and a connections board as SVG. They are deliberately renderer-light: the script asks `atelier/layout` for geometry, then writes plain SVG elements from the solved frames. Which part is layout and which part is rendering stays visible. + diff --git a/docs/images/align-bottom-center.svg b/docs/images/align-bottom-center.svg new file mode 100644 index 0000000..74157df --- /dev/null +++ b/docs/images/align-bottom-center.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-bottom-left.svg b/docs/images/align-bottom-left.svg new file mode 100644 index 0000000..02164ff --- /dev/null +++ b/docs/images/align-bottom-left.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-bottom-right.svg b/docs/images/align-bottom-right.svg new file mode 100644 index 0000000..2628e70 --- /dev/null +++ b/docs/images/align-bottom-right.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-cross-center.svg b/docs/images/align-cross-center.svg new file mode 100644 index 0000000..996976f --- /dev/null +++ b/docs/images/align-cross-center.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-cross-end.svg b/docs/images/align-cross-end.svg new file mode 100644 index 0000000..283dd7f --- /dev/null +++ b/docs/images/align-cross-end.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-cross-start.svg b/docs/images/align-cross-start.svg new file mode 100644 index 0000000..ccb3d78 --- /dev/null +++ b/docs/images/align-cross-start.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-cross-stretch.svg b/docs/images/align-cross-stretch.svg new file mode 100644 index 0000000..50e96be --- /dev/null +++ b/docs/images/align-cross-stretch.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-middle-center.svg b/docs/images/align-middle-center.svg new file mode 100644 index 0000000..c239b8b --- /dev/null +++ b/docs/images/align-middle-center.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-middle-left.svg b/docs/images/align-middle-left.svg new file mode 100644 index 0000000..ff3d2b5 --- /dev/null +++ b/docs/images/align-middle-left.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-middle-right.svg b/docs/images/align-middle-right.svg new file mode 100644 index 0000000..5fa0f28 --- /dev/null +++ b/docs/images/align-middle-right.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-top-center.svg b/docs/images/align-top-center.svg new file mode 100644 index 0000000..53faa62 --- /dev/null +++ b/docs/images/align-top-center.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-top-left.svg b/docs/images/align-top-left.svg new file mode 100644 index 0000000..94fa7db --- /dev/null +++ b/docs/images/align-top-left.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/align-top-right.svg b/docs/images/align-top-right.svg new file mode 100644 index 0000000..4508783 --- /dev/null +++ b/docs/images/align-top-right.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/aspectframe.svg b/docs/images/aspectframe.svg new file mode 100644 index 0000000..e37b6ef --- /dev/null +++ b/docs/images/aspectframe.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/badge-end.svg b/docs/images/badge-end.svg new file mode 100644 index 0000000..b9c520b --- /dev/null +++ b/docs/images/badge-end.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/docs/images/badge-start.svg b/docs/images/badge-start.svg new file mode 100644 index 0000000..e0a3b7d --- /dev/null +++ b/docs/images/badge-start.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/docs/images/bounds-union.svg b/docs/images/bounds-union.svg new file mode 100644 index 0000000..71d6863 --- /dev/null +++ b/docs/images/bounds-union.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/docs/images/connection-l.svg b/docs/images/connection-l.svg new file mode 100644 index 0000000..cd75b5b --- /dev/null +++ b/docs/images/connection-l.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/docs/images/connection-straight.svg b/docs/images/connection-straight.svg new file mode 100644 index 0000000..209e3be --- /dev/null +++ b/docs/images/connection-straight.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/docs/images/connection-z.svg b/docs/images/connection-z.svg new file mode 100644 index 0000000..d01bfca --- /dev/null +++ b/docs/images/connection-z.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/docs/images/connectionlabel-above.svg b/docs/images/connectionlabel-above.svg new file mode 100644 index 0000000..edb8580 --- /dev/null +++ b/docs/images/connectionlabel-above.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/docs/images/connectionlabel-below.svg b/docs/images/connectionlabel-below.svg new file mode 100644 index 0000000..4321e04 --- /dev/null +++ b/docs/images/connectionlabel-below.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/docs/images/connectionlabel-centered.svg b/docs/images/connectionlabel-centered.svg new file mode 100644 index 0000000..a109c46 --- /dev/null +++ b/docs/images/connectionlabel-centered.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/docs/images/constraints-loose.svg b/docs/images/constraints-loose.svg new file mode 100644 index 0000000..7b36111 --- /dev/null +++ b/docs/images/constraints-loose.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/constraints-tight.svg b/docs/images/constraints-tight.svg new file mode 100644 index 0000000..aef7ebf --- /dev/null +++ b/docs/images/constraints-tight.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/distribute-center.svg b/docs/images/distribute-center.svg new file mode 100644 index 0000000..6e2f1e5 --- /dev/null +++ b/docs/images/distribute-center.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/distribute-end.svg b/docs/images/distribute-end.svg new file mode 100644 index 0000000..57d2146 --- /dev/null +++ b/docs/images/distribute-end.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/distribute-space-around.svg b/docs/images/distribute-space-around.svg new file mode 100644 index 0000000..1dd0e00 --- /dev/null +++ b/docs/images/distribute-space-around.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/distribute-space-between.svg b/docs/images/distribute-space-between.svg new file mode 100644 index 0000000..a97eb9e --- /dev/null +++ b/docs/images/distribute-space-between.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/distribute-space-evenly.svg b/docs/images/distribute-space-evenly.svg new file mode 100644 index 0000000..83cd85d --- /dev/null +++ b/docs/images/distribute-space-evenly.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/distribute-start.svg b/docs/images/distribute-start.svg new file mode 100644 index 0000000..7cb116a --- /dev/null +++ b/docs/images/distribute-start.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/edgeband.svg b/docs/images/edgeband.svg new file mode 100644 index 0000000..858b5fa --- /dev/null +++ b/docs/images/edgeband.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/figures.css b/docs/images/figures.css new file mode 100644 index 0000000..deb4429 --- /dev/null +++ b/docs/images/figures.css @@ -0,0 +1,9 @@ +.frame { fill: none; stroke: var(--color, currentColor); stroke-width: 2; opacity: .30; } +.item { fill: var(--color, currentColor); opacity: .16; } +.accent { fill: var(--accent, currentColor); } +.dashed { fill: none; stroke: var(--color, currentColor); stroke-width: 2; stroke-dasharray: 4 3; opacity: .45; } +.dashed-accent { fill: none; stroke: var(--accent, currentColor); stroke-width: 2; stroke-dasharray: 4 3; } +.connection { fill: none; stroke: var(--color, currentColor); stroke-width: 2; opacity: .55; } +.connection-accent { fill: none; stroke: var(--accent, currentColor); stroke-width: 2; } +.arrow { fill: var(--accent, currentColor); } +.label { fill: var(--color, currentColor); opacity: .7; font: 6px sans-serif; } diff --git a/docs/images/fit-contain.svg b/docs/images/fit-contain.svg new file mode 100644 index 0000000..dea1108 --- /dev/null +++ b/docs/images/fit-contain.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/fit-cover.svg b/docs/images/fit-cover.svg new file mode 100644 index 0000000..9a248dd --- /dev/null +++ b/docs/images/fit-cover.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/fit-fill.svg b/docs/images/fit-fill.svg new file mode 100644 index 0000000..2b94ba2 --- /dev/null +++ b/docs/images/fit-fill.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/fit-scale-down.svg b/docs/images/fit-scale-down.svg new file mode 100644 index 0000000..9933515 --- /dev/null +++ b/docs/images/fit-scale-down.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/grid-2x2.svg b/docs/images/grid-2x2.svg new file mode 100644 index 0000000..1a436ec --- /dev/null +++ b/docs/images/grid-2x2.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/docs/images/grid-3-columns.svg b/docs/images/grid-3-columns.svg new file mode 100644 index 0000000..25e12f9 --- /dev/null +++ b/docs/images/grid-3-columns.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/grid-column-span.svg b/docs/images/grid-column-span.svg new file mode 100644 index 0000000..3967b44 --- /dev/null +++ b/docs/images/grid-column-span.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/grid-dashed-slot.svg b/docs/images/grid-dashed-slot.svg new file mode 100644 index 0000000..4ae4861 --- /dev/null +++ b/docs/images/grid-dashed-slot.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/docs/images/grid-row-span.svg b/docs/images/grid-row-span.svg new file mode 100644 index 0000000..3d60255 --- /dev/null +++ b/docs/images/grid-row-span.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/group-bounds.svg b/docs/images/group-bounds.svg new file mode 100644 index 0000000..5680ded --- /dev/null +++ b/docs/images/group-bounds.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/docs/images/group.svg b/docs/images/group.svg new file mode 100644 index 0000000..66f70bf --- /dev/null +++ b/docs/images/group.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/inlinegroup-content.svg b/docs/images/inlinegroup-content.svg new file mode 100644 index 0000000..dd38695 --- /dev/null +++ b/docs/images/inlinegroup-content.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/inlinegroup-equal.svg b/docs/images/inlinegroup-equal.svg new file mode 100644 index 0000000..8871ece --- /dev/null +++ b/docs/images/inlinegroup-equal.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/insets-padding.svg b/docs/images/insets-padding.svg new file mode 100644 index 0000000..dbb28a1 --- /dev/null +++ b/docs/images/insets-padding.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/legend-horizontal.svg b/docs/images/legend-horizontal.svg new file mode 100644 index 0000000..1de193a --- /dev/null +++ b/docs/images/legend-horizontal.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/docs/images/legend-vertical.svg b/docs/images/legend-vertical.svg new file mode 100644 index 0000000..eb32f62 --- /dev/null +++ b/docs/images/legend-vertical.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/docs/images/overlay-badge.svg b/docs/images/overlay-badge.svg new file mode 100644 index 0000000..3c1685e --- /dev/null +++ b/docs/images/overlay-badge.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/overlay-center.svg b/docs/images/overlay-center.svg new file mode 100644 index 0000000..8cc045f --- /dev/null +++ b/docs/images/overlay-center.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/spacer.svg b/docs/images/spacer.svg new file mode 100644 index 0000000..83966f6 --- /dev/null +++ b/docs/images/spacer.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/images/stack-column.svg b/docs/images/stack-column.svg new file mode 100644 index 0000000..6303548 --- /dev/null +++ b/docs/images/stack-column.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/stack-gap-loose.svg b/docs/images/stack-gap-loose.svg new file mode 100644 index 0000000..43823c6 --- /dev/null +++ b/docs/images/stack-gap-loose.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/stack-gap-none.svg b/docs/images/stack-gap-none.svg new file mode 100644 index 0000000..6d39746 --- /dev/null +++ b/docs/images/stack-gap-none.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/stack-row.svg b/docs/images/stack-row.svg new file mode 100644 index 0000000..a208041 --- /dev/null +++ b/docs/images/stack-row.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/trackgroup-horizontal.svg b/docs/images/trackgroup-horizontal.svg new file mode 100644 index 0000000..69f6b6d --- /dev/null +++ b/docs/images/trackgroup-horizontal.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/images/trackgroup-vertical.svg b/docs/images/trackgroup-vertical.svg new file mode 100644 index 0000000..8d7df66 --- /dev/null +++ b/docs/images/trackgroup-vertical.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/docs/text.md b/docs/text.md new file mode 100644 index 0000000..a8e9574 --- /dev/null +++ b/docs/text.md @@ -0,0 +1,82 @@ +--- +order: 50 +--- +# Text And Inline Runs + +`TextBlock` gives renderers enough information to draw wrapped text without becoming a browser text engine. Measurement, wrapping, per-line frames, baselines, overflow, and font weight are layout concerns because they all change geometry. + +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Element\TextBlock; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\LayoutContext; +use Atelier\Layout\Text\FontWeight; + +$layout = TextBlock::of('label', 'A long label that wraps', 12) + ->weight(FontWeight::Bold) + ->lineHeight(1.2) + ->breakWords() + ->align(Alignment::Center, Alignment::End) + ->layout(new LayoutContext(), new Rect(0, 0, 120, 48)); +``` + +The result contains: + +- `frame`: the target rectangle. +- `lines`: `TextLineLayout` values with text, frame, and absolute baseline. +- `contentWidth` / `contentHeight`. +- `overflowX` / `overflowY`. +- `hasOverflow()`. +- `firstBaseline()` / `lastBaseline()`. + +## Measurement + +The default `CharWidthTextMeasurer` is deterministic and approximate. That is intentional: tests and server-side rendering need stable numbers, and a font engine gives neither. A more precise measurer can be injected through `LayoutContext`. + +`measureLine()` and `wrap()` accept `FontWeight::Normal` or `FontWeight::Bold`. The built-in measurer applies a deterministic bold width factor instead of consulting a font. Its constructor accepts `heightFactor`, `ascentFactor`, and `boldFactor`, so a consumer can preserve established baselines while still using the shared measurement API: + +```php +use Atelier\Layout\Text\CharWidthTextMeasurer; + +$context = new LayoutContext( + textMeasurer: new CharWidthTextMeasurer( + heightFactor: 1.4, + ascentFactor: 0.92, + ), +); +``` + +## Alignment + +Horizontal alignment positions each line inside the text frame. Vertical alignment positions the block inside the target rect. Both take the four [`Alignment` cases](composition/alignment.md), and in both `Stretch` behaves like `Start`: layout does not justify text. + +Use `End` vertical alignment for bottom-snapped multiline labels, which is the common case for captions under a figure. + +## Overflow + +Overflow is reported, never hidden. `overflowX` and `overflowY` are the amounts by which the content exceeds its frame, and `hasOverflow()` is the shortcut. A renderer decides what to do: clip, shrink the font, add an ellipsis, or grow the box. + +Leaving that decision to the consumer is why the package can serve a chart legend and a diagram node label with the same type. + +## Inline Runs + +`InlineGroup` lays out a row of items that share a baseline and a gap, when each item is a measured width rather than a full layout node. It is the lightweight form used for legend entries, key-value chips, and label runs. + +
+
A row of items whose widths follow their content
contentSized()
+
A row of items sharing one common width
equal()
+
+ +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Inline\InlineGroup; + +$row = InlineGroup::contentSized('tags') + ->gap(6) + ->align(Alignment::Center) + ->add('draft', preferredWidth: 40) + ->add('review', preferredWidth: 52) + ->place($frame); +``` + +`equal()` gives every item the same width, which keeps a run of chips on a regular rhythm. `contentSized()` lets each item keep its measured width. `add()` also accepts `minWidth` and `fixedWidth` when one item must not shrink. diff --git a/docs/tracks-bands-legends.md b/docs/tracks-bands-legends.md new file mode 100644 index 0000000..441eb14 --- /dev/null +++ b/docs/tracks-bands-legends.md @@ -0,0 +1,84 @@ +--- +order: 60 +--- +# Tracks, Bands And Legends + +Three helpers for the recurring furniture of a chart or a board: parallel lanes, an edge strip, and a key. Each takes a rectangle and returns placed frames. None of them draws anything. + +## Track Groups + +`TrackGroup` divides a canvas into parallel lanes along one axis, with an optional header and footer reserved at the ends. + +
+
A canvas divided into horizontal lanes with a header strip
horizontal()
+
A canvas divided into vertical lanes with a header strip
vertical()
+
+ +```php +use Atelier\Layout\Track\TrackGroup; +use Atelier\Layout\Value\InsetSpec; + +$placed = TrackGroup::horizontal('swimlanes') + ->padding(InsetSpec::px(12)) + ->gap(8) + ->headerSize(28) + ->equalTracks() + ->addTrack('backlog') + ->addTrack('doing') + ->addTrack('done') + ->place($canvas); +``` + +Three sizing policies decide how the lanes share the main axis: + +| Policy | Behaviour | +|---|---| +| `equalTracks()` | every lane gets the same size | +| `contentSizedTracks()` | each lane takes its preferred size | +| `stretchedTracks(float $flex)` | lanes share the leftover space | + +`addTrack()` accepts a `Dimension` or a preferred main size to override the group policy for one lane. Kanban columns, swimlanes, and Gantt rows are the usual consumers. When the lanes need two axes rather than one, reach for [Grid](composition/grid.md) instead. + +## Edge Bands + +`EdgeBand` reserves a strip along the top or bottom of a rectangle and returns both the strip and what remains. + +A rectangle split into a narrow strip along one edge and the remaining area + +```php +use Atelier\Layout\Band\EdgeBand; + +$placed = EdgeBand::top('axis') + ->bandSize(24) + ->gap(8) + ->place($available); +``` + +It answers the question every chart asks first: where does the axis go, and what is left for the plot. Splitting it out means the plot area is computed once, by one rule, instead of being open-coded next to each renderer. + +## Legends + +`LegendBlock` lays out entries made of a swatch and a label, in a column or a row. + +
+
A column of legend entries, each a small swatch beside a label
vertical()
+
A row of legend entries, each a small swatch beside a label
horizontal()
+
+ +```php +use Atelier\Layout\Alignment; +use Atelier\Layout\Legend\LegendBlock; + +$placed = LegendBlock::vertical('series') + ->swatchSize(10, 10) + ->labelGap(6) + ->gap(4) + ->align(Alignment::Start, Alignment::Start) + ->add('revenue', labelWidth: 54, labelHeight: 12) + ->add('cost', labelWidth: 38, labelHeight: 12) + ->place($available); +``` + +Label widths are supplied by the caller, measured with whatever measurer the consumer trusts. The legend places; it does not measure text on your behalf. See [Text And Inline Runs](text.md) if you need that measurement first. + +`gap()` separates entries, `labelGap()` separates a swatch from its own label. Two different rhythms, two different knobs. From 66a88e9ebd51189bf94ca399ba9a343bcf7ac7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Wed, 12 Aug 2026 05:28:05 +0200 Subject: [PATCH 2/7] docs: rewrite README around what a reader is deciding --- README.md | 476 ++++++++++-------------------------------------------- 1 file changed, 88 insertions(+), 388 deletions(-) diff --git a/README.md b/README.md index 8b2c7ff..1153d1c 100644 --- a/README.md +++ b/README.md @@ -1,449 +1,149 @@ -# Atelier Layout +

Atelier Layout

-Renderer-agnostic layout primitives. +

Spatial primitives that answer where things go, and return geometry rather than markup.

-`atelier/layout` answers one question before anything is rendered: +

+ PHP Version + Tests + PHPUnit + PHPStan + Stable + License +

-> Given a box and some constraints, where does every rectangle, label, link, and child frame go? - -It knows nothing about any output format, markup, or colour. It returns geometry that a renderer can draw. - -## Why It Exists - -Visual libraries repeat the same spatial work: - -- Frame `4%` canvas padding and fixed `10px` strokes. -- Stack rows or columns with gaps and alignment. -- Share space across grid tracks. -- Span slots. -- Keep text centered, bottom-snapped, wrapped, or overflow-aware. -- Compute a group frame around solved children. -- Draw links between boxes. -- Keep all of that deterministic enough to test. - -Without a shared layer, every renderer eventually grows its own incompatible mini-layout engine. - -## Installation - -```bash -composer require atelier/layout -``` - -Requires PHP 8.3+. - -## Status - -The package is usable as a shared spatial layer. It includes geometry values, -constraints, stack/grid/overlay/group composition, deterministic text layout, -shape-aware fitting, track/item/legend/band helpers, and link geometry between -boxes. - -It deliberately stops before rendering and semantics: markup output, parsing, -domain models, themes, markers, colours, and graph-ranking algorithms belong in -consumers. - -## Conventions - -Three promises the package makes, so nothing here is left to guess. - -### The y axis points down - -`Rect(x, y, width, height)` uses screen coordinates: `x` grows right, `y` grows -**down**, and the origin is the top-left corner. `y = 100` is below `y = 0`. -That is the SVG and Canvas convention, and it is what `Anchor::TopLeft`, -`Insets(top, right, bottom, left)` and `Rect::bottom()` all mean. - -This is a decision, not an oversight, and it will not change. The alternative -convention, `y` growing up with the origin at the bottom left, is the one -mathematical charts use. A consumer working that way flips in its **scale**, the -function that turns a data value into a pixel, which every chart library already -has. Carrying an orientation inside `Rect` would make `TopLeft` ambiguous and -`bottom()` wrong half the time, for a flip that belongs one layer up. - -### Zero in, zero out - -A rectangle with zero width or height is an ordinary state, not a caller -mistake: a collapsed panel, an empty list, a track that received no space, a -percentage of nothing. Every primitive accepts one and returns empty geometry -rather than raising. - -What matters is not that nothing crashes, but that nothing produces `NaN`. -Dividing by a zero dimension does not raise; it seeds a `NaN` that spreads -through every later computation and surfaces at the far end as coordinates no -renderer can draw, arbitrarily far from its cause. Negative dimensions are still -rejected, because those are caller mistakes. - -### Where the CSS vocabulary stops - -The package borrows CSS words on purpose: `gap`, `padding`, `align`, `stretch`, -`fr`, `Grid`, `Stack`. Reading `->gap(12)` should need no documentation. - -Borrowing the word means promising the behaviour, so here is where it does not -hold. These are simplifications, not bugs, but they will surprise you if nobody -says so. - -| Concept | CSS | Here | -|---|---|---| -| `align-items: stretch` | Leaves an item alone when its cross size is definite | `Alignment::Stretch` stretches unconditionally, ignoring a declared size | -| `1fr` track | Has an automatic minimum of the track's min-content, so content can force it wider | Fraction tracks split the available space and ignore content entirely | -| Percentage padding | Resolves **all four** sides against the inline size (the width) | Resolves top and bottom against the height, left and right against the width | - -Verified as matching CSS: `gap` adds no space at the edges, fraction-based `flex` -distributes free space proportionally, and `auto` tracks size to their content. - -Beyond that, `atelier/layout` is not a browser layout engine. CSS parity is not -a goal; see Deferred at the end. - -## Core Concepts - -The package has a few layers. You rarely touch all of them at once. - -### Geometry - -`Rect`, `Point`, `Size`, `Insets`, `Bounds`, `Circle`, `BoxModel`, `Fit` -- immutable values, plus shape-aware fitting and safe areas. - -### Constraints and solving - -`BoxConstraints` and `IntrinsicSize` give the min/preferred/max sizing vocabulary; `LayoutContext`, `LayoutSolver`, and `PlacedNode` solve a node tree into queryable frames. - -### Composition - -`Stack`, `Grid`, `Group`, `Overlay`, `Spacer`, `TextBlock` are the composable layout nodes. A horizontal `Stack` can align its children on a shared text baseline with `alignToBaseline()` instead of on their box edges. `PlacedGrid`/`GridSlot` expose placed track, slot, and named-area metadata when consumers need debugging, snapshots, or annotations. - -### Higher-level spatial helpers - -Named primitives for recurring visual structures: `TrackGroup`, `InlineGroup`, `LegendBlock`, `EdgeBand`, `AspectFrame`, `GroupBounds`. Each places frames a renderer can draw directly. - -### Connections - -`OrthogonalConnector` turns two boxes into a link you can draw. `RectIndex` answers collision and free-space queries for labels and badges. - -## Stack And Grid - -```php -use Atelier\Layout\Alignment; -use Atelier\Layout\Element\Frame; -use Atelier\Layout\Element\Grid; -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Grid\TrackSize; -use Atelier\Layout\LayoutContext; - -$grid = Grid::tracks('toolbar', [ - TrackSize::fixed(80), - TrackSize::fr(), - TrackSize::fixed(40), -], [ - TrackSize::fixed(32), -]) - ->gap(12) - ->align(Alignment::Stretch, Alignment::Center) - ->add(Frame::fixed('back', 80, 24)) - ->add(Frame::stretch('title'), alignX: Alignment::Center) - ->add(Frame::fixed('menu', 40, 24)); - -$result = $grid->solve(new LayoutContext(), Rect::fromSize(320, 48)); - -$bandFrame = $result->frameOf('title'); - -$layout = $grid->layout(new LayoutContext(), Rect::fromSize(320, 48)); -$layout->column(0); -$layout->row(0); -$layout->slot(1, 0); -$layout->slots(); -$layout->item('title'); -$layout->namedArea('title'); -$layout->frameOf('title'); -``` - -`Grid` supports fixed, auto, and fraction tracks; row tracks; column/row spans; global alignment; and per-item alignment overrides. -`PlacedGrid` exposes the placed track, slot, and named-area metadata when consumers need debugging, snapshots, or annotations. - -## Track Groups - -```php -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Track\TrackGroup; -use Atelier\Layout\Value\InsetSpec; - -$layout = TrackGroup::horizontal('board') - ->gap(10) - ->padding(InsetSpec::px(10)) - ->headerSize(20) - ->footerSize(10) - ->equalTracks() - ->addTrack('todo') - ->addTrack('doing') - ->place(Rect::fromSize(300, 120)); - -$todo = $layout->track('todo'); -$todo?->headerFrame; -$todo?->bodyFrame; -$todo?->footerFrame; -``` - -`TrackGroup` is for placed track geometry, not item placement. It gives a deterministic track frame plus header/body/footer bands, so renderers can place their own content without recomputing the track scaffold. - -## Inline Groups - -```php -use Atelier\Layout\Alignment; -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Inline\InlineGroup; - -$row = InlineGroup::contentSized('browse.tasks') - ->gap(10) - ->align(Alignment::Center) - ->add('choose-product', preferredWidth: 50, minWidth: 40) - ->add('enter-item', preferredWidth: 60, minWidth: 40) - ->place(Rect::fromSize(200, 40)); - -$row->item('choose-product'); -$row->overflowX; -``` - -`InlineGroup` is the companion to `TrackGroup`: it turns a horizontal run of items into placed frames, keeps the row aligned inside the available body, and reports overflow instead of letting items collide. - -## Legend Layout +Describe boxes, tracks, text, padding and constraints; get back frames any renderer can consume. +Nothing here emits SVG, HTML, or a DOM, and nothing here names a consumer. ```php -use Atelier\Layout\Alignment; -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Legend\LegendBlock; -use Atelier\Layout\Value\InsetSpec; - -$legend = LegendBlock::vertical('legend') +$result = Grid::tracks('row', [TrackSize::fixed(48), TrackSize::fr(), TrackSize::fixed(48)]) ->gap(8) - ->labelGap(6) - ->swatchSize(10, 10) - ->padding(InsetSpec::px(4)) - ->align(Alignment::Center, Alignment::Start) - ->add('api', 40, 12) - ->add('worker', 60, 12) - ->place(Rect::fromSize(120, 100)); - -$legend->entry('api'); -$legend->frame; -``` - -`LegendBlock` keeps the geometry of swatches and labels together so renderers can place legend rows without repeating the same packing math. + ->add(Frame::fixed('left', 32, 32)) + ->add(Frame::stretch('middle')) + ->add(Frame::fixed('right', 32, 32)) + ->solve(new LayoutContext(), Rect::fromSize(240, 48)); -## Edge Bands - -```php -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Band\EdgeBand; -use Atelier\Layout\Value\InsetSpec; - -$band = EdgeBand::top('surface') - ->bandSize(24) - ->gap(8) - ->padding(InsetSpec::px(4)) - ->place(Rect::fromSize(120, 100)); - -$band->bandFrame; -$band->contentFrame; +$result->frameOf('middle'); // Rect(56, 0, 128, 48) ``` -`EdgeBand` reserves a band frame and a content frame in one pass, which is useful for renderers that want band spacing separated from the rest of the surface without carrying painting policy into `layout`. +Separating the geometry from the drawing is what lets the same solver serve a chart legend, a +diagram node, and a page header. It is the layer `atelier/diagram` sits on. Backed by an +extensive test suite and PHPStan at its highest level. -## Aspect Frames - -```php -use Atelier\Layout\Aspect\AspectFrame; -use Atelier\Layout\Fit\FitMode; -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Value\InsetSpec; +**[Composition](#composition) · [Geometry](#geometry) · [Text](#text) · +[Links between boxes](#links-between-boxes) · [Charts and boards](#charts-and-boards) · +[Documentation](#documentation)** -$slot = AspectFrame::of('fitted frame', 16, 9) - ->padding(InsetSpec::px(4)) - ->fitMode(FitMode::Contain) - ->place(Rect::fromSize(200, 120)); - -$slot->fittedFrame; -``` - -`AspectFrame` keeps a fitted area at a stable ratio inside the available frame, with optional padding and fit behaviour. - -## Overflow - -Every solved node can report children that do not fit, whatever container placed them: - -```php -$placed = $stack->solve(new LayoutContext(), Rect::fromSize(200, 80)); +## Installation -$placed->overflows(); // bool -$placed->overflowingChildren(); // list +```bash +composer require atelier/layout ``` -Overflow is derived from the frames rather than stored by each container, so an anchored child pushed out by an offset, a fixed child larger than its slot, and a track that outgrew its band all surface the same way. It looks at direct children only: a node reports its own content, not its grandchildren's. +Requires PHP 8.3 or later. No dependencies. -## Text Layout +## Quick start ```php use Atelier\Layout\Alignment; use Atelier\Layout\Element\TextBlock; use Atelier\Layout\Geometry\Rect; use Atelier\Layout\LayoutContext; -use Atelier\Layout\Text\FontWeight; -$layout = TextBlock::of('label', 'Two line label', 12) - ->weight(FontWeight::Bold) +$text = TextBlock::of('caption', 'A label that wraps', 12) ->align(Alignment::Center, Alignment::End) - ->breakWords() - ->layout(new LayoutContext(), new Rect(0, 0, 80, 48)); + ->layout(new LayoutContext(), new Rect(0, 0, 90, 48)); -foreach ($layout->lines as $line) { - $line->text; - $line->frame; - $line->baseline; -} - -$layout->hasOverflow(); +$text->lines; // one frame and one baseline per line +$text->hasOverflow(); // reported, never hidden ``` -`maxLines()` caps the rendered lines: extra lines are dropped, `isTruncated()` -reports it, and vertical alignment uses the height actually rendered. Adding an -ellipsis is a rendering decision and stays with the consumer. - -Text layout returns wrapped lines, per-line frames, absolute baselines, and overflow state. The default measurer is deterministic and approximate; consumers can provide a more precise measurer through `LayoutContext`. -`CharWidthTextMeasurer` supports `FontWeight::Normal` / `FontWeight::Bold` and configurable line height/ascent/bold-width factors, so consumers can keep their renderer baselines stable while sharing the same measurement contract. +See [Getting started](docs/getting-started.md). -## Links Between Boxes +## Composition -Connect two boxes with a single call. You give it two rectangles; it gives you geometry -- a polyline, a label anchor, an arrowhead direction -- that a renderer draws. `atelier/layout` never emits SVG itself. +Six primitives, each answering one question about where a child ends up: stacks along one axis, +grids on two, alignment inside a box, distribution of what is left over, groups that carry a +bounding box, and overlays anchored to a corner. -```php -use Atelier\Layout\Connection\OrthogonalConnector; +Track sizes are fixed, content-sized, or flexible, and a grid auto-flows past its column count. +See [Stacks](docs/composition/stacks.md) and [Grid](docs/composition/grid.md). -$link = (new OrthogonalConnector())->connect($boxA, $boxB); +## Geometry -$link->points; // list -- the polyline to draw -$link->labelPoint; // where an edge label fits -$link->tipTangent; // arrowhead direction at the end -``` +Immutable values with no behaviour beyond their own maths: `Rect`, `Point`, `Size`, `Insets`, +`Bounds`, `Circle`, `BoxModel`, and a `RectIndex` for collision and occupancy queries. -The connector picks which sides to leave from based on the boxes' relative position and returns a deterministic right-angle (elbow) path. It is intentionally small: graph ranking, obstacle avoidance, and bundling belong in consumers until repeated use cases justify shared APIs. +Box constraints travel down a node tree during measurement and sizes travel back up, which is +what makes a layout solvable in one pass. See [Geometry](docs/geometry/overview.md). -### Advanced: explicit sides, labels, badges +## Text -When you need to pin the exact attachment side, place a collision-aware label, or add an endpoint badge: +Measurement, wrapping, per-line frames, baselines, and overflow, because all five change +geometry and none of them are the renderer's business. -```php -use Atelier\Layout\Geometry\Insets; -use Atelier\Layout\Geometry\RectIndex; -use Atelier\Layout\Geometry\Size; -use Atelier\Layout\Connection\OrthogonalConnector; -use Atelier\Layout\Connection\Port; -use Atelier\Layout\Connection\PortSide; -use Atelier\Layout\Connection\ConnectionEndpointBadge; -use Atelier\Layout\Connection\ConnectionEndpointBadgePlacement; -use Atelier\Layout\Connection\ConnectionLabel; -use Atelier\Layout\Connection\ConnectionLabelPlacement; - -$link = (new OrthogonalConnector())->connectPorts( - Port::on($boxA, PortSide::Right), - Port::on($boxB, PortSide::Left), -); - -$label = ConnectionLabel::for($link) - ->size(new Size(20, 10)) - ->avoid(RectIndex::from(['node.a' => $boxA])) - ->padding(Insets::all(6)) - ->placement(ConnectionLabelPlacement::Centered) - ->place(); - -$badge = ConnectionEndpointBadge::for($link, ConnectionEndpointBadgePlacement::End) - ->size(new Size(20, 10)) - ->padding(Insets::all(6)) - ->place(); -``` +The default measurer is deterministic and approximate on purpose: server-side rendering and +tests both need stable numbers, and a font engine gives neither. A more precise one is injected +through `LayoutContext`. See [Text](docs/text.md). -`RectIndex` is the shared collision index: `RectIndex::from([...])->isFree($candidate, ignore: [...])` reports whether a label or badge would overlap existing boxes. `ConnectionLabel` places text along the link; `ConnectionEndpointBadge` places a badge at the start or end. Neither embeds any domain semantics. +## Links between boxes -## Shape-Aware Layout +Where a connection leaves one rectangle, where it enters another, and the right-angle path in +between, as points rather than path data. ```php -use Atelier\Layout\Geometry\BoxModel; -use Atelier\Layout\Geometry\Circle; -use Atelier\Layout\Geometry\Insets; -use Atelier\Layout\Geometry\Point; -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Geometry\StrokePlacement; +use Atelier\Layout\Connection\OrthogonalConnector; -$content = (new BoxModel( - outer: new Rect(0, 0, 200, 400), - padding: new Insets(16, 8, 16, 8), - strokeWidth: 10, - strokePlacement: StrokePlacement::Inside, -))->contentRect(); +$connection = (new OrthogonalConnector())->connect($from, $to); -$safeLabelBox = (new Circle(new Point(100, 200), 80)) - ->safeSquare(Insets::all(8), strokeWidth: 10, strokePlacement: StrokePlacement::Inside); +$connection->points; // 100,40 -> 140,40 -> 140,80 -> 180,80 +$connection->labelPoint; // where an edge label belongs +$connection->tipTangent; // the final direction, for an arrowhead ``` -This is the path for target canvas sizing, percent padding, fixed contained strokes, and safe label areas inside a non-rectangular shape. +Label placement avoids solved rectangles deterministically. Graph ranking, obstacle avoidance +and edge bundling are deliberately not here. See [Links between boxes](docs/connections.md). -## Group Bounds +## Charts and boards -```php -use Atelier\Layout\Geometry\Insets; -use Atelier\Layout\Geometry\GroupBounds; -use Atelier\Layout\Geometry\Rect; -use Atelier\Layout\Geometry\Size; - -$frame = GroupBounds::fromFrames('subgraph.auth', [ - 'login' => new Rect(20, 40, 80, 24), - 'session' => new Rect(120, 52, 90, 24), -]) - ->padding(Insets::all(12)) - ->topReserve(28) - ->minSize(new Size(240, 120)) - ->frame(); -``` +The recurring furniture: parallel lanes with a reserved header, an edge strip for an axis, and a +key made of swatches and labels. Each takes a rectangle and returns placed frames. -`GroupBounds` is a small helper for consumers that already solved child frames and need a deterministic group frame, optional label reserve, and optional canvas clamp without introducing a full group-layout engine. +See [Tracks, bands and legends](docs/tracks-bands-legends.md). -## Relationship To Consumers +## Documentation -A consumer owns its own semantics, parsers, models, themes, and rendering. It -uses `atelier/layout` for the spatial math those layers keep re-deriving. +- [Getting started](docs/getting-started.md): solve a grid, some text, and a link. +- Composition: [stacks](docs/composition/stacks.md), [grid](docs/composition/grid.md), [alignment](docs/composition/alignment.md), [distribution](docs/composition/distribution.md), [groups](docs/composition/groups.md), [overlays](docs/composition/overlays.md). +- [Geometry](docs/geometry/overview.md): the value types, constraints, and fitting. +- [Text](docs/text.md): measuring, wrapping, baselines, overflow. +- [Links between boxes](docs/connections.md): ports, connectors, labels. +- [Tracks, bands and legends](docs/tracks-bands-legends.md): lanes, axis strips, keys. -The boundary runs one way: a consumer reads solved frames, and layout never -learns anything about the consumer. That is what keeps the package reusable -across unrelated renderers. +The full documentation is published at [ateliersvg.com/layout](https://ateliersvg.com/layout/). -## Documentation +## Contributing -- [Getting started](docs/getting-started.md) -- [Composition primitives](docs/composition.md) -- [Geometry and fitting](docs/geometry.md) -- [Text layout](docs/text-layout.md) -- [Links between boxes](docs/connections.md) -- [Demos](docs/demos.md) +Contributions are welcome. Visit the [project on GitHub](https://github.com/ateliersvg/layout) +to [report a bug](https://github.com/ateliersvg/layout/issues/new), +[suggest a feature](https://github.com/ateliersvg/layout/issues/new), or +[open a pull request](https://github.com/ateliersvg/layout/pulls). -## Development +Before submitting code, run: ```bash -composer install -composer test # phpunit -composer sa # phpstan, level max -composer cs # php-cs-fixer --dry-run --diff -composer cs:fix -composer qa # cs + sa + test -composer docs:images # regenerate docs/images (needs the sibling atelier/svg checkout) -composer validate --strict -php examples/smoke.php -php examples/composition-demo.php -php examples/composition-gallery.php -php examples/connections-demo.php +composer qa # PHP-CS-Fixer, PHPStan at level max, and PHPUnit ``` -The package is deliberately small and test-driven. New public primitives should have exact numeric tests before another package depends on them. +A new public primitive needs exact numeric tests before a consumer depends on it. + +## Support -## Deferred +Bug reports, security disclosures, and contribution guidelines are collected at +[ateliersvg.com/support](https://ateliersvg.com/support/). -`atelier/layout` is not a browser layout engine and not a full constraint solver. CSS parity, Cassowary-style constraints, force-directed graph layout, font shaping, and obstacle-aware link routing are deferred until multiple consumers need them. +Atelier is maintained by Simon André. Sharing the package or +[starring it on GitHub](https://github.com/ateliersvg/layout) helps more than you would think. ## License -MIT. +Atelier Layout is released under the [MIT License](LICENSE). From fc90b2986ca8e66a4d5064814058523f2d1db375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Wed, 12 Aug 2026 16:43:37 +0200 Subject: [PATCH 3/7] chore: ignore generated examples output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7af2b02..1c7fb5a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /composer.lock /phpstan.neon /phpunit.xml +/examples/output/ From 6301ecee0e03ad70b82867239ec804d98c6310a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Wed, 12 Aug 2026 16:43:38 +0200 Subject: [PATCH 4/7] docs: track the examples the documentation links to --- examples/composition-demo.php | 146 +++++++++++ examples/composition-gallery.php | 314 +++++++++++++++++++++++ examples/connections-demo.php | 47 ++++ examples/figures/bootstrap.php | 26 ++ examples/figures/figures.php | 210 +++++++++++++++ examples/figures/scenes/composition.php | 160 ++++++++++++ examples/figures/scenes/connectors.php | 142 ++++++++++ examples/figures/scenes/geometry.php | 101 ++++++++ examples/figures/scenes/helpers.php | 124 +++++++++ examples/illustrations.php | 28 ++ examples/legacy/illustrations.string.php | 258 +++++++++++++++++++ examples/smoke.php | 100 ++++++++ 12 files changed, 1656 insertions(+) create mode 100644 examples/composition-demo.php create mode 100644 examples/composition-gallery.php create mode 100644 examples/connections-demo.php create mode 100644 examples/figures/bootstrap.php create mode 100644 examples/figures/figures.php create mode 100644 examples/figures/scenes/composition.php create mode 100644 examples/figures/scenes/connectors.php create mode 100644 examples/figures/scenes/geometry.php create mode 100644 examples/figures/scenes/helpers.php create mode 100644 examples/illustrations.php create mode 100644 examples/legacy/illustrations.string.php create mode 100644 examples/smoke.php diff --git a/examples/composition-demo.php b/examples/composition-demo.php new file mode 100644 index 0000000..f807abc --- /dev/null +++ b/examples/composition-demo.php @@ -0,0 +1,146 @@ +inset($margin); + +$grid = Grid::tracks('dashboard', [TrackSize::fr(), TrackSize::fr()], [TrackSize::fixed(44), TrackSize::fr()]) + ->padding(InsetSpec::px(12)) + ->gap(12) + ->align(Alignment::Stretch, Alignment::Stretch) + ->add(TextBlock::of('title', 'Pipeline status overview', 14), columnSpan: 2, alignX: Alignment::Center, alignY: Alignment::Center) + ->add(Frame::preferred('build', 120, 72)) + ->add(Frame::preferred('deploy', 120, 72)); + +$result = $grid->solve($context, $page); +$titleText = TextBlock::of('title.text', 'Pipeline status overview', 14) + ->align(Alignment::Center, Alignment::Center) + ->layout($context, $result->frameOf('title') ?? $page); +$buildLabel = TextBlock::of('build.label', 'Build queue', 12) + ->align(Alignment::Center, Alignment::End) + ->layout($context, $result->frameOf('build') ?? $page); +$deployLabel = TextBlock::of('deploy.label', 'Deploy target wraps', 12) + ->align(Alignment::Center, Alignment::End) + ->layout($context, $result->frameOf('deploy') ?? $page); + +$build = $result->frameOf('build'); +$deploy = $result->frameOf('deploy'); +$title = $result->frameOf('title'); +if (!$build instanceof Rect || !$deploy instanceof Rect || !$title instanceof Rect) { + throw new RuntimeException('Demo frames are missing.'); +} + +$groupBounds = Bounds::expand(Bounds::of($build, $deploy), Insets::all(8)); +$connection = (new OrthogonalConnector())->connect($title, $deploy); + +$svg = svg( + $canvas, + [ + rectNode($canvas, '#ffffff', '#94a3b8', 'canvas'), + rectNode($page, '#f8fafc', '#64748b', 'margin box'), + rectNode($groupBounds, 'none', '#0f766e', 'group bounds'), + rectNode($result->frameOf('title'), '#e0f2fe', '#0284c7', 'grid title slot'), + rectNode($build, '#dcfce7', '#16a34a', 'build slot'), + rectNode($deploy, '#fee2e2', '#dc2626', 'deploy slot'), + textNodes($titleText), + textNodes($buildLabel), + textNodes($deployLabel), + connectionNode($connection), + ], +); + +$outputDir = __DIR__.'/output'; +if (!is_dir($outputDir) && !mkdir($outputDir, 0o755, true)) { + throw new RuntimeException('Cannot create demo output directory.'); +} +$target = __DIR__.'/output/composition-demo.svg'; +file_put_contents($target, $svg); + +echo 'canvas='.formatRect($canvas)."\n"; +echo 'marginBox='.formatRect($page)."\n"; +echo 'title='.formatRect($title)."\n"; +echo 'build='.formatRect($build)."\n"; +echo 'deploy='.formatRect($deploy)."\n"; +echo 'groupBounds='.formatRect($groupBounds)."\n"; +echo 'connection='.implode(' -> ', array_map(static fn ($point): string => number($point->x).','.number($point->y), $connection->points))."\n"; +echo 'wrote='.$target."\n"; + +/** + * @param list $nodes + */ +function svg(Rect $canvas, array $nodes): string +{ + return ''."\n" + .implode("\n", $nodes) + ."\n\n"; +} + +function rectNode(?Rect $rect, string $fill, string $stroke, string $label): string +{ + if (!$rect instanceof Rect) { + return ''; + } + + return ''.htmlspecialchars($label, ENT_QUOTES).''; +} + +function connectionNode(Atelier\Layout\Connection\OrthogonalConnection $connection): string +{ + $commands = []; + foreach ($connection->points as $index => $point) { + $commands[] = (0 === $index ? 'M' : 'L').' '.number($point->x).' '.number($point->y); + } + + return 'connection label point'; +} + +function textNodes(Atelier\Layout\Text\TextLayout $layout): string +{ + $nodes = []; + foreach ($layout->lines as $line) { + $nodes[] = ''.htmlspecialchars($line->text, ENT_QUOTES).''; + } + + return implode("\n", $nodes); +} + +function formatRect(?Rect $rect): string +{ + if (!$rect instanceof Rect) { + return 'missing'; + } + + return number($rect->x).','.number($rect->y).','.number($rect->width).','.number($rect->height); +} + +function number(float $value): string +{ + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); +} diff --git a/examples/composition-gallery.php b/examples/composition-gallery.php new file mode 100644 index 0000000..e001455 --- /dev/null +++ b/examples/composition-gallery.php @@ -0,0 +1,314 @@ +inset(Insets::all(28)); + $grid = Grid::tracks('dashboard', [TrackSize::fixed(160), TrackSize::fr(), TrackSize::fr()], [ + TrackSize::fixed(56), + TrackSize::fr(1.2), + TrackSize::fr(), + ]) + ->padding(InsetSpec::px(16)) + ->gap(16) + ->align(Alignment::Stretch, Alignment::Stretch) + ->add(TextBlock::of('title', 'Layout-composed dashboard', 18), columnSpan: 3, alignX: Alignment::Start, alignY: Alignment::Center) + ->add(Frame::stretch('nav'), rowSpan: 2) + ->add(Frame::stretch('main'), columnSpan: 2) + ->add(Frame::stretch('queue')) + ->add(Frame::stretch('detail')); + + $result = $solver->solve($grid, $page); + $main = requiredFrame($result->frameOf('main'), 'main'); + $queue = requiredFrame($result->frameOf('queue'), 'queue'); + $detail = requiredFrame($result->frameOf('detail'), 'detail'); + $cards = Bounds::expand(Bounds::of($main, $queue, $detail), Insets::all(10)); + + $nodes = [ + rect($canvas, '#09090b', null, 0), + rect($page, '#111827', '#334155', 8), + rect($cards, 'none', '#22d3ee', 8, 1.5, 0.55), + rect(requiredFrame($result->frameOf('title'), 'title'), '#0f172a', '#1e293b', 6), + rect(requiredFrame($result->frameOf('nav'), 'nav'), '#18181b', '#475569', 6), + rect($main, '#0f766e', '#2dd4bf', 6, 1.5, 0.38), + rect($queue, '#4c1d95', '#a78bfa', 6, 1.5, 0.45), + rect($detail, '#7f1d1d', '#fca5a5', 6, 1.5, 0.42), + label($context, 'title.label', 'Grid tracks, spans, padding, and group bounds', requiredFrame($result->frameOf('title'), 'title'), 18, '#f8fafc', Alignment::Start, Alignment::Center), + label($context, 'nav.label', "Fixed\nrail", requiredFrame($result->frameOf('nav'), 'nav')->inset(Insets::all(18)), 15, '#cbd5e1', Alignment::Center, Alignment::Center), + label($context, 'main.label', "Spanning content area\nstretches across two columns", $main->inset(Insets::all(20)), 18, '#ccfbf1', Alignment::Center, Alignment::Center), + label($context, 'queue.label', "Bottom-snapped\nwrapped label", $queue->inset(Insets::all(16)), 15, '#ede9fe', Alignment::Center, Alignment::End), + label($context, 'detail.label', "Independent slot\nsame grid solve", $detail->inset(Insets::all(16)), 15, '#fee2e2', Alignment::Center, Alignment::End), + ]; + + $target = $outputDir.'/layout-dashboard.svg'; + file_put_contents($target, svg($canvas, flatten($nodes))); + + return $target; +} + +function writeOverlaySpecimen(LayoutSolver $solver, LayoutContext $context, string $outputDir): string +{ + $canvas = Rect::fromSize(640, 360); + $frame = $canvas->inset(new Insets(34, 38, 34, 38)); + $overlay = Overlay::of('overlay') + ->padding(InsetSpec::percent(4)) + ->add(Frame::fixed('center-card', 280, 150), Anchor::Center, Anchor::Center) + ->add(Frame::fixed('top-badge', 128, 34), Anchor::TopCenter, Anchor::TopCenter, 0, 14) + ->add(Frame::fixed('right-tag', 108, 32), Anchor::CenterRight, Anchor::CenterRight, -18, 0) + ->add(Frame::fixed('bottom-note', 240, 44), Anchor::BottomCenter, Anchor::BottomCenter, 0, -16); + + $result = $solver->solve($overlay, $frame); + $center = requiredFrame($result->frameOf('center-card'), 'center-card'); + $badge = requiredFrame($result->frameOf('top-badge'), 'top-badge'); + $tag = requiredFrame($result->frameOf('right-tag'), 'right-tag'); + $note = requiredFrame($result->frameOf('bottom-note'), 'bottom-note'); + + $nodes = [ + rect($canvas, '#030712', null, 0), + rect($frame, '#111827', '#374151', 10), + rect($center, '#164e63', '#67e8f9', 8, 1.5, 0.65), + rect($badge, '#f59e0b', null, 6, 1.0, 0.95), + rect($tag, '#be123c', null, 6, 1.0, 0.95), + rect($note, '#1f2937', '#94a3b8', 6), + line($badge->x + $badge->width / 2.0, $badge->y + $badge->height, $center->x + $center->width / 2.0, $center->y, '#fbbf24'), + label($context, 'overlay.center', "Overlay anchors\nsnap children to a shared rect", $center->inset(Insets::all(18)), 18, '#ecfeff', Alignment::Center, Alignment::Center), + label($context, 'overlay.badge', 'Top badge', $badge, 13, '#111827', Alignment::Center, Alignment::Center), + label($context, 'overlay.tag', 'Right tag', $tag, 13, '#fff1f2', Alignment::Center, Alignment::Center), + label($context, 'overlay.note', 'Padding is computed from the host size', $note->inset(Insets::all(8)), 12, '#cbd5e1', Alignment::Center, Alignment::Center), + ]; + + $target = $outputDir.'/layout-overlays.svg'; + file_put_contents($target, svg($canvas, flatten($nodes))); + + return $target; +} + +function writeConnectionSpecimen(LayoutSolver $solver, LayoutContext $context, string $outputDir): string +{ + $canvas = Rect::fromSize(780, 360); + $page = $canvas->inset(Insets::all(32)); + $stack = Stack::row('pipeline') + ->padding(InsetSpec::px(18)) + ->gap(24) + ->alignItems(Alignment::Center) + ->distribute(Distribution::SpaceBetween) + ->add(Frame::fixed('ingest', 146, 82)) + ->add(Frame::fixed('normalize', 160, 104)) + ->add(Frame::fixed('review', 146, 82)) + ->add(Frame::fixed('publish', 146, 82)); + + $result = $solver->solve($stack, $page); + $ids = ['ingest', 'normalize', 'review', 'publish']; + $frames = []; + foreach ($ids as $id) { + $frames[$id] = requiredFrame($result->frameOf($id), $id); + } + + $connector = new OrthogonalConnector(); + $connections = [ + $connector->connect($frames['ingest'], $frames['normalize']), + $connector->connect($frames['normalize'], $frames['review']), + $connector->connect($frames['review'], $frames['publish']), + ]; + $groupBounds = Bounds::expand(Bounds::of(...array_values($frames)), Insets::all(12)); + + $nodes = [ + rect($canvas, '#020617', null, 0), + rect($page, '#0f172a', '#334155', 10), + rect($groupBounds, 'none', '#38bdf8', 10, 1.5, 0.55), + ]; + + foreach ($connections as $connection) { + $nodes[] = path($connection, '#e2e8f0'); + $nodes[] = circle($connection->labelPoint->x, $connection->labelPoint->y, 3.5, '#38bdf8'); + } + + foreach ($frames as $id => $frame) { + $nodes[] = rect($frame, '#1e293b', '#94a3b8', 7); + $nodes[] = label($context, 'connection.'.$id, ucfirst($id), $frame, 15, '#f8fafc', Alignment::Center, Alignment::Center); + } + + $target = $outputDir.'/layout-connections-board.svg'; + file_put_contents($target, svg($canvas, flatten($nodes))); + + return $target; +} + +/** + * @param list> $nodes + * + * @return list + */ +function flatten(array $nodes): array +{ + $flat = []; + foreach ($nodes as $node) { + foreach ((array) $node as $part) { + if ('' !== $part) { + $flat[] = $part; + } + } + } + + return $flat; +} + +/** + * @param list $nodes + */ +function svg(Rect $canvas, array $nodes): string +{ + return ''."\n" + .implode("\n", $nodes) + ."\n\n"; +} + +function requiredFrame(?Rect $frame, string $id): Rect +{ + if (!$frame instanceof Rect) { + throw new RuntimeException(\sprintf('Missing frame "%s".', $id)); + } + + return $frame; +} + +function rect(Rect $rect, ?string $fill, ?string $stroke, float $radius, float $strokeWidth = 1.0, ?float $opacity = null): string +{ + $attributes = [ + 'x="'.num($rect->x).'"', + 'y="'.num($rect->y).'"', + 'width="'.num($rect->width).'"', + 'height="'.num($rect->height).'"', + 'rx="'.num($radius).'"', + 'fill="'.($fill ?? 'none').'"', + ]; + if (null !== $stroke) { + $attributes[] = 'stroke="'.$stroke.'"'; + $attributes[] = 'stroke-width="'.num($strokeWidth).'"'; + } + if (null !== $opacity) { + $attributes[] = 'opacity="'.num($opacity).'"'; + } + + return ''; +} + +function line(float $x1, float $y1, float $x2, float $y2, string $stroke): string +{ + return ''; +} + +function circle(float $cx, float $cy, float $r, string $fill): string +{ + return ''; +} + +function path(Atelier\Layout\Connection\OrthogonalConnection $connection, string $stroke): string +{ + $commands = []; + foreach ($connection->points as $index => $point) { + $commands[] = (0 === $index ? 'M' : 'L').' '.num($point->x).' '.num($point->y); + } + + return ''; +} + +/** + * @return list + */ +function label( + LayoutContext $context, + string $id, + string $text, + Rect $frame, + float $fontSize, + string $fill, + Alignment $alignX, + Alignment $alignY, +): array { + $layout = TextBlock::of($id, $text, $fontSize) + ->align($alignX, $alignY) + ->breakWords() + ->layout($context, $frame); + + return text($layout, $fontSize, $fill, $alignX); +} + +/** + * @return list + */ +function text(TextLayout $layout, float $fontSize, string $fill, Alignment $alignX): array +{ + $anchor = match ($alignX) { + Alignment::Start, Alignment::Stretch => 'start', + Alignment::Center => 'middle', + Alignment::End => 'end', + }; + + $nodes = []; + foreach ($layout->lines as $line) { + $x = match ($alignX) { + Alignment::Start, Alignment::Stretch => $line->frame->x, + Alignment::Center => $line->frame->x + $line->frame->width / 2.0, + Alignment::End => $line->frame->x + $line->frame->width, + }; + $nodes[] = ''.htmlspecialchars($line->text, ENT_QUOTES).''; + } + + return $nodes; +} + +function num(float $value): string +{ + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); +} diff --git a/examples/connections-demo.php b/examples/connections-demo.php new file mode 100644 index 0000000..2b4b2d6 --- /dev/null +++ b/examples/connections-demo.php @@ -0,0 +1,47 @@ + [new Rect(20, 20, 60, 40), new Rect(160, 50, 70, 40)], + 'top-to-bottom' => [new Rect(60, 20, 70, 40), new Rect(80, 140, 70, 40)], + 'right-to-left' => [new Rect(180, 30, 70, 40), new Rect(40, 60, 60, 40)], +]; + +foreach ($cases as $name => [$from, $to]) { + $connection = $connector->connect($from, $to); + + echo $name."\n"; + echo ' from='.formatRect($from)."\n"; + echo ' to='.formatRect($to)."\n"; + echo ' points='.implode(' -> ', array_map(static fn ($point): string => number($point->x).','.number($point->y), $connection->points))."\n"; + echo ' label='.number($connection->labelPoint->x).','.number($connection->labelPoint->y)."\n"; + echo ' tangent='.number($connection->tipTangent->x).','.number($connection->tipTangent->y)."\n"; +} + +function formatRect(Rect $rect): string +{ + return number($rect->x).','.number($rect->y).','.number($rect->width).','.number($rect->height); +} + +function number(float $value): string +{ + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); +} diff --git a/examples/figures/bootstrap.php b/examples/figures/bootstrap.php new file mode 100644 index 0000000..f3c7786 --- /dev/null +++ b/examples/figures/bootstrap.php @@ -0,0 +1,26 @@ + __DIR__.'/../../src', + 'Atelier\\Svg\\' => __DIR__.'/../../../svg/src', + ]; + foreach ($map as $prefix => $root) { + if (\str_starts_with($class, $prefix)) { + $path = $root.'/'.\str_replace('\\', '/', \substr($class, \strlen($prefix))).'.php'; + if (\is_file($path)) { + require $path; + } + + return; + } + } +}); diff --git a/examples/figures/figures.php b/examples/figures/figures.php new file mode 100644 index 0000000..10c6590 --- /dev/null +++ b/examples/figures/figures.php @@ -0,0 +1,210 @@ + callable(LayoutContext, Rect): list and reuse + * the Drawable shapes and helpers defined here. + */ + +namespace Atelier\Layout\Examples\Figures; + +use Atelier\Layout\Geometry\Point; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Result\PlacedNode; +use Atelier\Svg\Element\StyleElement; +use Atelier\Svg\Svg; + +const CANVAS = 100.0; +const DISPLAY = 160; +const FRAME_RX = 8.0; +const ITEM_RX = 2.0; + +/** The single source of truth for figure styling: classes, never inline colors. */ +const FIGURE_STYLE = <<<'CSS' + .frame { fill: none; stroke: var(--color, currentColor); stroke-width: 2; opacity: .30; } + .item { fill: var(--color, currentColor); opacity: .16; } + .accent { fill: var(--accent, currentColor); } + .dashed { fill: none; stroke: var(--color, currentColor); stroke-width: 2; stroke-dasharray: 4 3; opacity: .45; } + .dashed-accent { fill: none; stroke: var(--accent, currentColor); stroke-width: 2; stroke-dasharray: 4 3; } + .connection { fill: none; stroke: var(--color, currentColor); stroke-width: 2; opacity: .55; } + .connection-accent { fill: none; stroke: var(--accent, currentColor); stroke-width: 2; } + .arrow { fill: var(--accent, currentColor); } + .label { fill: var(--color, currentColor); opacity: .7; font: 6px sans-serif; } + CSS; + +// --------------------------------------------------------------------------- +// Drawables: each shape knows its own semantic class and how to draw itself. +// --------------------------------------------------------------------------- + +interface Drawable +{ + public function draw(Svg $svg): void; +} + +/** A rounded rectangle: a container child, a focused item, or an empty slot. */ +final readonly class BoxShape implements Drawable +{ + public function __construct(public Rect $rect, public string $class, public float $rx = ITEM_RX) + { + } + + public function draw(Svg $svg): void + { + $svg->rect($this->rect->x, $this->rect->y, $this->rect->width, $this->rect->height, ['class' => $this->class, 'rx' => $this->rx]); + } +} + +/** An orthogonal link drawn through a polyline of points. */ +final readonly class LinkPath implements Drawable +{ + /** @param list $points */ + public function __construct(public array $points, public string $class) + { + } + + public function draw(Svg $svg): void + { + $d = ''; + foreach ($this->points as $i => $p) { + $d .= (0 === $i ? 'M' : ' L').' '.num($p->x).' '.num($p->y); + } + $svg->path($d, ['class' => $this->class]); + } +} + +/** A filled triangle at a link's tip, pointing along its tangent. */ +final readonly class ArrowHead implements Drawable +{ + public function __construct(public Point $tip, public Point $tangent, public string $class = 'arrow') + { + } + + public function draw(Svg $svg): void + { + $len = hypot($this->tangent->x, $this->tangent->y) ?: 1.0; + $ux = $this->tangent->x / $len; + $uy = $this->tangent->y / $len; + $size = 5.0; + $half = 2.6; + $bx = $this->tip->x - $ux * $size; + $by = $this->tip->y - $uy * $size; + $d = 'M '.num($this->tip->x).' '.num($this->tip->y) + .' L '.num($bx - $uy * $half).' '.num($by + $ux * $half) + .' L '.num($bx + $uy * $half).' '.num($by - $ux * $half).' Z'; + $svg->path($d, ['class' => $this->class]); + } +} + +// --- Terse drawable factories for scenes ------------------------------------ + +function box(Rect $rect, string $class): BoxShape +{ + return new BoxShape($rect, $class); +} + +/** @param list $points */ +function link(array $points, string $class = 'connection-accent'): LinkPath +{ + return new LinkPath($points, $class); +} + +function arrow(Point $tip, Point $tangent): ArrowHead +{ + return new ArrowHead($tip, $tangent); +} + +/** Resolve a node frame or fail loudly. */ +function frame(PlacedNode $solved, string $id): Rect +{ + $rect = $solved->frameOf($id); + if (!$rect instanceof Rect) { + throw new \RuntimeException("missing frame: $id"); + } + + return $rect; +} + +/** Accent the focused id, plain item for the rest. @return list */ +function items(PlacedNode $solved, string $accentId, string ...$rest): array +{ + $out = [box(frame($solved, $accentId), 'accent')]; + foreach ($rest as $id) { + $out[] = box(frame($solved, $id), 'item'); + } + + return $out; +} + +function num(float $value): string +{ + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/** + * Builds one figure with atelier/svg: a frame, the drawables, and the shared + * stylesheet -- class-only, no inline colors. + * + * @param list $drawables + */ +function renderFigure(string $id, Rect $container, array $drawables): Svg +{ + $svg = Svg::create(DISPLAY, DISPLAY); + $svg->rect($container->x, $container->y, $container->width, $container->height, ['class' => 'frame', 'rx' => FRAME_RX]); + + foreach ($drawables as $drawable) { + $drawable->draw($svg); + } + + $root = $svg->getDocument()->getRootElement(); + if (null === $root) { + throw new \RuntimeException('no root element'); + } + $root->setAttribute('viewBox', '0 0 '.num(CANVAS).' '.num(CANVAS)); + $root->setAttribute('role', 'img'); + $root->setAttribute('aria-label', ucfirst(str_replace('-', ' ', $id))); + $root->prependChild((new StyleElement())->setContent("\n".FIGURE_STYLE."\n ")); + + return $svg; +} + +/** + * Render every scene in the registry to $outDir, plus the shared figures.css. + * + * @param array> $registry + */ +function renderAll(array $registry, string $outDir): void +{ + if (!is_dir($outDir) && !mkdir($outDir, 0o755, true) && !is_dir($outDir)) { + throw new \RuntimeException('Cannot create output dir.'); + } + file_put_contents($outDir.'/figures.css', ltrim(FIGURE_STYLE)."\n"); + + $context = new \Atelier\Layout\LayoutContext(); + $container = Rect::fromSize(CANVAS, CANVAS)->inset(\Atelier\Layout\Geometry\Insets::all(8)); + + $ok = 0; + $errors = []; + foreach ($registry as $id => $build) { + try { + renderFigure($id, $container, $build($context, $container))->savePretty($outDir.'/'.$id.'.svg'); + ++$ok; + } catch (\Throwable $e) { + $errors[$id] = $e->getMessage(); + } + } + + echo "generated $ok / ".\count($registry)." figures into $outDir\n"; + foreach ($errors as $id => $msg) { + echo " FAILED $id: $msg\n"; + } +} diff --git a/examples/figures/scenes/composition.php b/examples/figures/scenes/composition.php new file mode 100644 index 0000000..424c9c5 --- /dev/null +++ b/examples/figures/scenes/composition.php @@ -0,0 +1,160 @@ + callable(LayoutContext, Rect): list. + */ + +namespace Atelier\Layout\Examples\Figures; + +use Atelier\Layout\Alignment; +use Atelier\Layout\Anchor; +use Atelier\Layout\Distribution; +use Atelier\Layout\Element\Frame; +use Atelier\Layout\Element\Grid; +use Atelier\Layout\Element\Group; +use Atelier\Layout\Element\Overlay; +use Atelier\Layout\Element\Spacer; +use Atelier\Layout\Element\Stack; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Grid\TrackSize; +use Atelier\Layout\LayoutContext; +use Atelier\Layout\Value\InsetSpec; + +$scenes = []; + +// Alignment -- the 9-grid, a packed group aligned inside its box. +$alignMap = ['start' => Alignment::Start, 'center' => Alignment::Center, 'end' => Alignment::End]; +$rowLabel = ['start' => 'top', 'center' => 'middle', 'end' => 'bottom']; +$colLabel = ['start' => 'left', 'center' => 'center', 'end' => 'right']; +foreach (['start', 'center', 'end'] as $vy) { + foreach (['start', 'center', 'end'] as $vx) { + $ax = $alignMap[$vx]; + $ay = $alignMap[$vy]; + $scenes['align-'.$rowLabel[$vy].'-'.$colLabel[$vx]] = static function (LayoutContext $ctx, Rect $c) use ($ax, $ay): array { + $group = Stack::row('row')->gap(4) + ->add(Frame::fixed('it0', 15, 15))->add(Frame::fixed('it1', 15, 15))->add(Frame::fixed('it2', 15, 15)); + $solved = Group::of('root')->align($ax, $ay)->padding(InsetSpec::px(8))->add($group)->solve($ctx, $c); + + return items($solved, 'it0', 'it1', 'it2'); + }; + } +} + +// Stack -- one per direction; first item accented to read the order. +foreach (['row' => true, 'column' => false] as $dir => $horizontal) { + $scenes['stack-'.$dir] = static function (LayoutContext $ctx, Rect $c) use ($horizontal): array { + [$w, $h] = $horizontal ? [18.0, 44.0] : [44.0, 18.0]; + $stack = ($horizontal ? Stack::row('row') : Stack::column('row')) + ->gap(8)->alignItems(Alignment::Center)->distribute(Distribution::Center) + ->add(Frame::fixed('it0', $w, $h))->add(Frame::fixed('it1', $w, $h))->add(Frame::fixed('it2', $w, $h)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Distribution -- along the main axis. +foreach ([ + 'start' => Distribution::Start, 'center' => Distribution::Center, 'end' => Distribution::End, + 'space-between' => Distribution::SpaceBetween, 'space-around' => Distribution::SpaceAround, 'space-evenly' => Distribution::SpaceEvenly, +] as $name => $dist) { + $scenes['distribute-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($dist): array { + $stack = Stack::row('row')->gap(0)->alignItems(Alignment::Center)->distribute($dist)->padding(InsetSpec::px(6)) + ->add(Frame::fixed('it0', 16, 40))->add(Frame::fixed('it1', 16, 40))->add(Frame::fixed('it2', 16, 40)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Cross-axis alignment -- items of varying height in a row. +foreach (['start' => Alignment::Start, 'center' => Alignment::Center, 'end' => Alignment::End, 'stretch' => Alignment::Stretch] as $name => $al) { + $scenes['align-cross-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($al): array { + $mk = static fn (string $id, float $h) => Alignment::Stretch === $al ? Frame::preferred($id, 16, $h) : Frame::fixed($id, 16, $h); + $stack = Stack::row('row')->gap(8)->alignItems($al)->distribute(Distribution::Center)->padding(InsetSpec::px(6)) + ->add($mk('it0', 24))->add($mk('it1', 44))->add($mk('it2', 32)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Gap -- tight vs loose. +foreach (['none' => 0.0, 'loose' => 14.0] as $name => $gap) { + $scenes['stack-gap-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($gap): array { + $stack = Stack::row('row')->gap($gap)->alignItems(Alignment::Center)->distribute(Distribution::Center)->padding(InsetSpec::px(6)) + ->add(Frame::fixed('it0', 16, 40))->add(Frame::fixed('it1', 16, 40))->add(Frame::fixed('it2', 16, 40)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Group -- a compact group centered in the box. +$scenes['group'] = static function (LayoutContext $ctx, Rect $c): array { + $group = Stack::row('row')->gap(4)->add(Frame::fixed('it0', 16, 16))->add(Frame::fixed('it1', 16, 16))->add(Frame::fixed('it2', 16, 16)); + + return items(Group::of('root')->align(Alignment::Center, Alignment::Center)->add($group)->solve($ctx, $c), 'it0', 'it1', 'it2'); +}; + +// Grid -- structures, span, and an empty (dashed) slot. +$scenes['grid-2x2'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::tracks('g', [TrackSize::fr(), TrackSize::fr()], [TrackSize::fr(), TrackSize::fr()]) + ->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10))->add(Frame::preferred('it1', 10, 10))->add(Frame::preferred('it2', 10, 10))->add(Frame::preferred('it3', 10, 10)); + + return items($g->solve($ctx, $c), 'it0', 'it1', 'it2', 'it3'); +}; +$scenes['grid-3-columns'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::columns('g', 3)->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10))->add(Frame::preferred('it1', 10, 10))->add(Frame::preferred('it2', 10, 10)); + + return items($g->solve($ctx, $c), 'it0', 'it1', 'it2'); +}; +$scenes['grid-column-span'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::tracks('g', [TrackSize::fr(), TrackSize::fr()], [TrackSize::fr(), TrackSize::fr()]) + ->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10), columnSpan: 2)->add(Frame::preferred('it1', 10, 10))->add(Frame::preferred('it2', 10, 10)); + + return items($g->solve($ctx, $c), 'it0', 'it1', 'it2'); +}; +$scenes['grid-row-span'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::tracks('g', [TrackSize::fr(), TrackSize::fr()], [TrackSize::fr(), TrackSize::fr()]) + ->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10), rowSpan: 2)->add(Frame::preferred('it1', 10, 10))->add(Frame::preferred('it2', 10, 10)); + + return items($g->solve($ctx, $c), 'it0', 'it1', 'it2'); +}; +$scenes['grid-dashed-slot'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::tracks('g', [TrackSize::fr(), TrackSize::fr()], [TrackSize::fr(), TrackSize::fr()]) + ->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10))->add(Frame::preferred('it1', 10, 10))->add(Frame::preferred('it2', 10, 10))->add(Frame::preferred('slot', 10, 10)); + $s = $g->solve($ctx, $c); + + return [box(frame($s, 'it0'), 'accent'), box(frame($s, 'it1'), 'item'), box(frame($s, 'it2'), 'item'), box(frame($s, 'slot'), 'dashed')]; +}; + +// Overlay -- centered, and a corner badge. +$scenes['overlay-center'] = static function (LayoutContext $ctx, Rect $c): array { + $o = Overlay::of('o')->add(Frame::preferred('base', 60, 60))->add(Frame::fixed('it0', 26, 26)); + $s = $o->solve($ctx, $c); + + return [box(frame($s, 'base'), 'item'), box(frame($s, 'it0'), 'accent')]; +}; +$scenes['overlay-badge'] = static function (LayoutContext $ctx, Rect $c): array { + $o = Overlay::of('o')->padding(InsetSpec::px(6)) + ->add(Frame::preferred('base', 72, 72)) + ->add(Frame::fixed('it0', 22, 22), Anchor::TopRight, Anchor::TopRight, offsetX: -6, offsetY: 6); + $s = $o->solve($ctx, $c); + + return [box(frame($s, 'base'), 'item'), box(frame($s, 'it0'), 'accent')]; +}; + +// Spacer -- pushes items to opposite ends. +$scenes['spacer'] = static function (LayoutContext $ctx, Rect $c): array { + $stack = Stack::row('row')->alignItems(Alignment::Center) + ->add(Frame::fixed('it0', 20, 40))->add(new Spacer('sp'))->add(Frame::fixed('it1', 20, 40)); + + return items($stack->solve($ctx, $c), 'it0', 'it1'); +}; + +return $scenes; diff --git a/examples/figures/scenes/connectors.php b/examples/figures/scenes/connectors.php new file mode 100644 index 0000000..269a506 --- /dev/null +++ b/examples/figures/scenes/connectors.php @@ -0,0 +1,142 @@ + callable(LayoutContext, Rect): list. + * + * Convention: source box drawn as 'item', target box as 'dashed', the link via + * link($connection->points), the arrow at the placed tip, and any placed label or + * badge frame as 'accent'. Boxes are placed relative to the container Rect $c. + */ + +namespace Atelier\Layout\Examples\Figures; + +use Atelier\Layout\Connection\ConnectionEndpointBadge; +use Atelier\Layout\Connection\ConnectionEndpointBadgePlacement; +use Atelier\Layout\Connection\ConnectionLabel; +use Atelier\Layout\Connection\ConnectionLabelPlacement; +use Atelier\Layout\Connection\OrthogonalConnection; +use Atelier\Layout\Connection\OrthogonalConnector; +use Atelier\Layout\Connection\Port; +use Atelier\Layout\Connection\PortSide; +use Atelier\Layout\Geometry\Insets; +use Atelier\Layout\Geometry\Point; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Geometry\Size; +use Atelier\Layout\LayoutContext; + +$scenes = []; + +$connector = new OrthogonalConnector(); + +// A straight horizontal connection between two centered boxes -- shared by the label +// and badge figures so the placement is the only thing that changes. +$straightConnection = static function (Rect $c) use ($connector): array { + $src = new Rect($c->x + 4, $c->y + 34, 20, 16); + $tgt = new Rect($c->x + 58, $c->y + 34, 20, 16); + + return [$src, $tgt, $connector->connect($src, $tgt)]; +}; + +// connection-straight -- aligned boxes collapse to a single segment. +$scenes['connection-straight'] = static function (LayoutContext $ctx, Rect $c) use ($connector): array { + $src = new Rect($c->x + 6, $c->y + 34, 24, 16); + $tgt = new Rect($c->x + 54, $c->y + 34, 24, 16); + $connection = $connector->connect($src, $tgt); + + return [ + box($src, 'item'), + box($tgt, 'dashed'), + link($connection->points), + arrow($connection->endPoint(), $connection->tipTangent), + ]; +}; + +// connection-l -- a single right-angle bend (source bottom port to target left port). +$scenes['connection-l'] = static function (LayoutContext $ctx, Rect $c): array { + $src = new Rect($c->x + 8, $c->y + 8, 24, 16); + $tgt = new Rect($c->x + 48, $c->y + 44, 24, 16); + $start = Port::on($src, PortSide::Bottom); + $end = Port::on($tgt, PortSide::Left); + $corner = new Point($start->point->x, $end->point->y); + $points = [$start->point, $corner, $end->point]; + $connection = new OrthogonalConnection( + $start, + $end, + $points, + OrthogonalConnection::segmentsForPoints($points), + $corner, + new Point($end->point->x - $corner->x, $end->point->y - $corner->y), + ); + + return [ + box($src, 'item'), + box($tgt, 'dashed'), + link($connection->points), + arrow($connection->endPoint(), $connection->tipTangent), + ]; +}; + +// connection-z -- offset boxes get a mid-point Z (horizontal, vertical, horizontal). +$scenes['connection-z'] = static function (LayoutContext $ctx, Rect $c) use ($connector): array { + $src = new Rect($c->x + 4, $c->y + 14, 24, 16); + $tgt = new Rect($c->x + 52, $c->y + 50, 24, 16); + $connection = $connector->connect($src, $tgt); + + return [ + box($src, 'item'), + box($tgt, 'dashed'), + link($connection->points), + arrow($connection->endPoint(), $connection->tipTangent), + ]; +}; + +// Connection labels -- centered on, above, and below the middle segment. +foreach ([ + 'centered' => ConnectionLabelPlacement::Centered, + 'above' => ConnectionLabelPlacement::Above, + 'below' => ConnectionLabelPlacement::Below, +] as $name => $placement) { + $scenes['connectionlabel-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($straightConnection, $placement): array { + [$src, $tgt, $connection] = $straightConnection($c); + $label = ConnectionLabel::for($connection) + ->size(new Size(24, 10)) + ->padding(Insets::all(4)) + ->placement($placement) + ->place(); + + return [ + box($src, 'item'), + box($tgt, 'dashed'), + link($connection->points), + arrow($connection->endPoint(), $connection->tipTangent), + box($label->frame, 'accent'), + ]; + }; +} + +// Endpoint badges -- a marker pinned to the start or the end of the connection. +foreach ([ + 'start' => ConnectionEndpointBadgePlacement::Start, + 'end' => ConnectionEndpointBadgePlacement::End, +] as $name => $placement) { + $scenes['badge-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($straightConnection, $placement): array { + [$src, $tgt, $connection] = $straightConnection($c); + $badge = ConnectionEndpointBadge::for($connection, $placement) + ->size(new Size(12, 12)) + ->padding(Insets::all(4)) + ->place(); + + return [ + box($src, 'item'), + box($tgt, 'dashed'), + link($connection->points), + arrow($connection->endPoint(), $connection->tipTangent), + box($badge->frame, 'accent'), + ]; + }; +} + +return $scenes; diff --git a/examples/figures/scenes/geometry.php b/examples/figures/scenes/geometry.php new file mode 100644 index 0000000..6c5f4dd --- /dev/null +++ b/examples/figures/scenes/geometry.php @@ -0,0 +1,101 @@ + callable(LayoutContext, Rect): list. + */ + +namespace Atelier\Layout\Examples\Figures; + +use Atelier\Layout\Constraint\BoxConstraints; +use Atelier\Layout\Fit\Fit; +use Atelier\Layout\Fit\FitMode; +use Atelier\Layout\Geometry\Bounds; +use Atelier\Layout\Geometry\GroupBounds; +use Atelier\Layout\Geometry\Insets; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Geometry\Size; +use Atelier\Layout\LayoutContext; + +$scenes = []; + +// Fit -- a source size fitted into a square target box (dashed), result accented. +// Source aspect differs from the target so each mode reads distinctly. +$fitTarget = new Rect(24.0, 24.0, 52.0, 52.0); +foreach ([ + 'contain' => [new Size(60.0, 40.0), FitMode::Contain], // letterboxed inside the box + 'cover' => [new Size(60.0, 40.0), FitMode::Cover], // fills the box, overflows one axis + 'scale-down' => [new Size(30.0, 20.0), FitMode::ScaleDown], // small source kept at natural size + 'fill' => [new Size(60.0, 40.0), FitMode::Fill], // stretched to the box exactly +] as $name => [$source, $mode]) { + $scenes['fit-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($fitTarget, $source, $mode): array { + $fitted = Fit::rect($source, $fitTarget, $mode); + + return [box($fitted, 'accent'), box($fitTarget, 'dashed')]; + }; +} + +// BoxConstraints -- the constraint box (dashed) and the constrained content (accent). +$constraintBox = new Rect(24.0, 24.0, 52.0, 52.0); +$desired = new Size(30.0, 20.0); +// Tight: content is forced to the exact size -- accent fills the box. +$scenes['constraints-tight'] = static function (LayoutContext $ctx, Rect $c) use ($constraintBox, $desired): array { + $size = BoxConstraints::tight($constraintBox->width, $constraintBox->height)->constrain($desired); + + return [box(new Rect($constraintBox->x, $constraintBox->y, $size->width, $size->height), 'accent'), box($constraintBox, 'dashed')]; +}; +// Loose: content keeps its smaller desired size under a max bound. +$scenes['constraints-loose'] = static function (LayoutContext $ctx, Rect $c) use ($constraintBox, $desired): array { + $size = (new BoxConstraints(maxWidth: $constraintBox->width, maxHeight: $constraintBox->height))->constrain($desired); + + return [box(new Rect($constraintBox->x, $constraintBox->y, $size->width, $size->height), 'accent'), box($constraintBox, 'dashed')]; +}; + +// Bounds -- scattered item rects and their union as a dashed-accent outline. +$scenes['bounds-union'] = static function (LayoutContext $ctx, Rect $c): array { + $rects = [ + new Rect(20.0, 22.0, 18.0, 16.0), + new Rect(54.0, 30.0, 16.0, 20.0), + new Rect(34.0, 56.0, 22.0, 14.0), + ]; + $union = Bounds::fromRects($rects); + if (!$union instanceof Rect) { + return []; + } + $out = [box($union, 'dashed-accent')]; + foreach ($rects as $r) { + $out[] = box($r, 'item'); + } + + return $out; +}; + +// Insets -- outer box (dashed) and the padded content (accent) via Rect::inset. +$scenes['insets-padding'] = static function (LayoutContext $ctx, Rect $c): array { + $outer = new Rect(20.0, 20.0, 60.0, 60.0); + + return [box($outer->inset(Insets::all(12.0)), 'accent'), box($outer, 'dashed')]; +}; + +// GroupBounds -- input frames (items) and the placed group bounds (dashed-accent). +$scenes['group-bounds'] = static function (LayoutContext $ctx, Rect $c): array { + $frames = [ + 'a' => new Rect(28.0, 30.0, 16.0, 14.0), + 'b' => new Rect(50.0, 34.0, 14.0, 18.0), + 'c' => new Rect(36.0, 54.0, 18.0, 12.0), + ]; + $groupBounds = GroupBounds::fromFrames('group', $frames)->padding(Insets::all(6.0))->frame(); + if (!$groupBounds instanceof Rect) { + return []; + } + $out = [box($groupBounds, 'dashed-accent')]; + foreach ($frames as $r) { + $out[] = box($r, 'item'); + } + + return $out; +}; + +return $scenes; diff --git a/examples/figures/scenes/helpers.php b/examples/figures/scenes/helpers.php new file mode 100644 index 0000000..c951347 --- /dev/null +++ b/examples/figures/scenes/helpers.php @@ -0,0 +1,124 @@ + callable(LayoutContext, Rect): list. + */ + +namespace Atelier\Layout\Examples\Figures; + +use Atelier\Layout\Alignment; +use Atelier\Layout\Aspect\AspectFrame; +use Atelier\Layout\Band\EdgeBand; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Inline\InlineGroup; +use Atelier\Layout\LayoutContext; +use Atelier\Layout\Legend\LegendBlock; +use Atelier\Layout\Track\TrackGroup; +use Atelier\Layout\Value\InsetSpec; + +$scenes = []; + +// TrackGroup -- equal tracks split the canvas along an axis; one track accented. +$scenes['trackgroup-horizontal'] = static function (LayoutContext $ctx, Rect $c): array { + $pack = TrackGroup::horizontal('tracks')->equalTracks()->gap(8)->padding(InsetSpec::px(6)) + ->addTrack('l0')->addTrack('l1')->addTrack('l2'); + $layout = $pack->place($c); + + $out = []; + foreach ($layout->tracks as $track) { + $out[] = box($track->frame, 'l1' === $track->id ? 'accent' : 'item'); + } + + return $out; +}; +$scenes['trackgroup-vertical'] = static function (LayoutContext $ctx, Rect $c): array { + $pack = TrackGroup::vertical('tracks')->equalTracks()->gap(8)->padding(InsetSpec::px(6)) + ->addTrack('l0')->addTrack('l1')->addTrack('l2'); + $layout = $pack->place($c); + + $out = []; + foreach ($layout->tracks as $track) { + $out[] = box($track->frame, 'l0' === $track->id ? 'accent' : 'item'); + } + + return $out; +}; + +// InlineGroup -- a row of items; equal widths vs content-sized widths; one accented. +$scenes['inlinegroup-equal'] = static function (LayoutContext $ctx, Rect $c): array { + $row = InlineGroup::equal('row')->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Center) + ->add('c0')->add('c1')->add('c2'); + $layout = $row->place($c); + + $out = []; + foreach ($layout->items as $item) { + $out[] = box($item->frame, 'c1' === $item->id ? 'accent' : 'item'); + } + + return $out; +}; +$scenes['inlinegroup-content'] = static function (LayoutContext $ctx, Rect $c): array { + $row = InlineGroup::contentSized('row')->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Center) + ->add('c0', preferredWidth: 16)->add('c1', preferredWidth: 32)->add('c2', preferredWidth: 20); + $layout = $row->place($c); + + $out = []; + foreach ($layout->items as $item) { + $out[] = box($item->frame, 'c1' === $item->id ? 'accent' : 'item'); + } + + return $out; +}; + +// LegendBlock -- swatch + label entries; one swatch accented, labels as items. +$scenes['legend-vertical'] = static function (LayoutContext $ctx, Rect $c): array { + $legend = LegendBlock::vertical('legend')->swatchSize(12, 12)->labelGap(6)->gap(10) + ->padding(InsetSpec::px(6))->align(Alignment::Start, Alignment::Center) + ->add('e0', 36, 9)->add('e1', 36, 9)->add('e2', 36, 9); + $layout = $legend->place($c); + + $out = []; + foreach ($layout->entries as $entry) { + $out[] = box($entry->swatchFrame, 'e0' === $entry->id ? 'accent' : 'item'); + $out[] = box($entry->labelFrame, 'item'); + } + + return $out; +}; +$scenes['legend-horizontal'] = static function (LayoutContext $ctx, Rect $c): array { + $legend = LegendBlock::horizontal('legend')->swatchSize(12, 12)->labelGap(5)->gap(10) + ->padding(InsetSpec::px(6))->align(Alignment::Center, Alignment::Center) + ->add('e0', 14, 9)->add('e1', 14, 9)->add('e2', 14, 9); + $layout = $legend->place($c); + + $out = []; + foreach ($layout->entries as $entry) { + $out[] = box($entry->swatchFrame, 'e1' === $entry->id ? 'accent' : 'item'); + $out[] = box($entry->labelFrame, 'item'); + } + + return $out; +}; + +// EdgeBand -- a dashed edge band above an item body. +$scenes['edgeband'] = static function (LayoutContext $ctx, Rect $c): array { + $band = EdgeBand::top('title')->bandSize(20)->gap(8)->padding(InsetSpec::px(6)); + $layout = $band->place($c); + + return [box($layout->bandFrame, 'dashed-accent'), box($layout->contentFrame, 'item')]; +}; + +// AspectFrame -- a ratio-fitted fitted frame (accent) letterboxed inside its slot (dashed). +$scenes['aspectframe'] = static function (LayoutContext $ctx, Rect $c): array { + $frame = AspectFrame::of('frame', 16, 9)->padding(InsetSpec::px(8)); + $layout = $frame->place($c); + + return [box($layout->contentFrame, 'dashed'), box($layout->fittedFrame, 'accent')]; +}; + +return $scenes; diff --git a/examples/illustrations.php b/examples/illustrations.php new file mode 100644 index 0000000..4cf02df --- /dev/null +++ b/examples/illustrations.php @@ -0,0 +1,28 @@ +.svg. + * + * Run: php examples/illustrations.php + */ + +namespace Atelier\Layout\Examples\Figures; + +require __DIR__.'/figures/bootstrap.php'; +require __DIR__.'/figures/figures.php'; + +$registry = [ + ...require __DIR__.'/figures/scenes/composition.php', + ...require __DIR__.'/figures/scenes/geometry.php', + ...require __DIR__.'/figures/scenes/helpers.php', + ...require __DIR__.'/figures/scenes/connectors.php', +]; + +renderAll($registry, __DIR__.'/../docs/images'); diff --git a/examples/legacy/illustrations.string.php b/examples/legacy/illustrations.string.php new file mode 100644 index 0000000..efc166b --- /dev/null +++ b/examples/legacy/illustrations.string.php @@ -0,0 +1,258 @@ +.svg + * + * Color contract (inline SVG): .frame/.item use var(--color, currentColor) (items at + * .16 opacity = theme-adaptive gray); .accent uses var(--accent, currentColor). + * Monochrome by default; pass --accent from the HTML to light the highlighted item. + */ + +$layoutSrc = __DIR__.'/../src'; +spl_autoload_register(static function (string $class) use ($layoutSrc): void { + $prefix = 'Atelier\\Layout\\'; + if (!str_starts_with($class, $prefix)) { + return; + } + $path = $layoutSrc.'/'.str_replace('\\', '/', substr($class, \strlen($prefix))).'.php'; + if (is_file($path)) { + require $path; + } +}); + +use Atelier\Layout\Alignment; +use Atelier\Layout\Anchor; +use Atelier\Layout\Distribution; +use Atelier\Layout\Element\Frame; +use Atelier\Layout\Element\Grid; +use Atelier\Layout\Element\Group; +use Atelier\Layout\Element\Overlay; +use Atelier\Layout\Element\Spacer; +use Atelier\Layout\Element\Stack; +use Atelier\Layout\Geometry\Insets; +use Atelier\Layout\Geometry\Rect; +use Atelier\Layout\Grid\TrackSize; +use Atelier\Layout\LayoutContext; +use Atelier\Layout\Result\PlacedNode; +use Atelier\Layout\Value\InsetSpec; + +const CANVAS = 100.0; +const FRAME_RX = 8.0; +const ITEM_RX = 2.0; + +$ctx = new LayoutContext(); +$container = Rect::fromSize(CANVAS, CANVAS)->inset(Insets::all(8)); + +/** resolve a node frame or fail loudly */ +function frame(PlacedNode $s, string $id): Rect +{ + $r = $s->frameOf($id); + if (!$r instanceof Rect) { + throw new RuntimeException("missing frame: $id"); + } + + return $r; +} + +/** @return list */ +function items(PlacedNode $s, string $accentId, string ...$rest): array +{ + $out = [['rect' => frame($s, $accentId), 'accent' => true]]; + foreach ($rest as $id) { + $out[] = ['rect' => frame($s, $id), 'accent' => false]; + } + + return $out; +} + +// --------------------------------------------------------------------------- +// Variant registry: id => callable(LayoutContext, Rect): list<{rect, accent}> +// --------------------------------------------------------------------------- +$registry = []; + +// Alignment -- the 9-grid, via Group aligning a small packed group. +$alignMap = [ + 'start' => Alignment::Start, 'center' => Alignment::Center, 'end' => Alignment::End, +]; +$rowLabel = ['start' => 'top', 'center' => 'middle', 'end' => 'bottom']; +$colLabel = ['start' => 'left', 'center' => 'center', 'end' => 'right']; +foreach (['start', 'center', 'end'] as $vy) { + foreach (['start', 'center', 'end'] as $vx) { + $id = 'align-'.$rowLabel[$vy].'-'.$colLabel[$vx]; + $ax = $alignMap[$vx]; + $ay = $alignMap[$vy]; + $registry[$id] = static function (LayoutContext $ctx, Rect $c) use ($ax, $ay): array { + $group = Stack::horizontal('row')->gap(4) + ->add(Frame::fixed('it0', 15, 15))->add(Frame::fixed('it1', 15, 15))->add(Frame::fixed('it2', 15, 15)); + $s = Group::centered('root')->align($ax, $ay)->padding(InsetSpec::px(8))->add($group)->solve($ctx, $c); + + return items($s, 'it0', 'it1', 'it2'); + }; + } +} + +// Stack -- one per direction. +foreach (['row' => true, 'column' => false] as $dir => $horizontal) { + $registry['stack-'.$dir] = static function (LayoutContext $ctx, Rect $c) use ($horizontal): array { + [$w, $h] = $horizontal ? [18.0, 44.0] : [44.0, 18.0]; + $stack = ($horizontal ? Stack::horizontal('row') : Stack::vertical('row')) + ->gap(8)->align(Alignment::Center)->distribute(Distribution::Center) + ->add(Frame::fixed('it0', $w, $h))->add(Frame::fixed('it1', $w, $h))->add(Frame::fixed('it2', $w, $h)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Distribution -- along the main axis (horizontal). +foreach ([ + 'start' => Distribution::Start, 'center' => Distribution::Center, 'end' => Distribution::End, + 'space-between' => Distribution::SpaceBetween, 'space-around' => Distribution::SpaceAround, + 'space-evenly' => Distribution::SpaceEvenly, +] as $name => $dist) { + $registry['distribute-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($dist): array { + $stack = Stack::horizontal('row')->gap(0)->align(Alignment::Center)->distribute($dist)->padding(InsetSpec::px(6)) + ->add(Frame::fixed('it0', 16, 40))->add(Frame::fixed('it1', 16, 40))->add(Frame::fixed('it2', 16, 40)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Cross-axis alignment -- items of varying height in a horizontal stack. +foreach (['start' => Alignment::Start, 'center' => Alignment::Center, 'end' => Alignment::End, 'stretch' => Alignment::Stretch] as $name => $al) { + $registry['align-cross-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($al): array { + $mk = static fn (string $id, float $h) => Alignment::Stretch === $al + ? Frame::preferred($id, 16, $h) : Frame::fixed($id, 16, $h); + $stack = Stack::horizontal('row')->gap(8)->align($al)->distribute(Distribution::Center)->padding(InsetSpec::px(6)) + ->add($mk('it0', 24))->add($mk('it1', 44))->add($mk('it2', 32)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Gap -- tight vs loose. +foreach (['none' => 0.0, 'loose' => 14.0] as $name => $gap) { + $registry['stack-gap-'.$name] = static function (LayoutContext $ctx, Rect $c) use ($gap): array { + $stack = Stack::horizontal('row')->gap($gap)->align(Alignment::Center)->distribute(Distribution::Center)->padding(InsetSpec::px(6)) + ->add(Frame::fixed('it0', 16, 40))->add(Frame::fixed('it1', 16, 40))->add(Frame::fixed('it2', 16, 40)); + + return items($stack->solve($ctx, $c), 'it0', 'it1', 'it2'); + }; +} + +// Group -- a packed group centered in the box. +$registry['group'] = static function (LayoutContext $ctx, Rect $c): array { + $group = Stack::horizontal('row')->gap(4) + ->add(Frame::fixed('it0', 16, 16))->add(Frame::fixed('it1', 16, 16))->add(Frame::fixed('it2', 16, 16)); + + return items(Group::centered('root')->align(Alignment::Center, Alignment::Center)->add($group)->solve($ctx, $c), 'it0', 'it1', 'it2'); +}; + +// Grid -- 2x2, 3 columns, and a column span. +$registry['grid-2x2'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::tracks('g', [TrackSize::fr(), TrackSize::fr()], [TrackSize::fr(), TrackSize::fr()]) + ->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10))->add(Frame::preferred('it1', 10, 10)) + ->add(Frame::preferred('it2', 10, 10))->add(Frame::preferred('it3', 10, 10)); + + return items($g->solve($ctx, $c), 'it0', 'it1', 'it2', 'it3'); +}; +$registry['grid-3-columns'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::tracks('g', [TrackSize::fr(), TrackSize::fr(), TrackSize::fr()]) + ->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10))->add(Frame::preferred('it1', 10, 10))->add(Frame::preferred('it2', 10, 10)); + + return items($g->solve($ctx, $c), 'it0', 'it1', 'it2'); +}; +$registry['grid-column-span'] = static function (LayoutContext $ctx, Rect $c): array { + $g = Grid::tracks('g', [TrackSize::fr(), TrackSize::fr()], [TrackSize::fr(), TrackSize::fr()]) + ->gap(8)->padding(InsetSpec::px(6))->align(Alignment::Stretch, Alignment::Stretch) + ->add(Frame::preferred('it0', 10, 10), columnSpan: 2) + ->add(Frame::preferred('it1', 10, 10))->add(Frame::preferred('it2', 10, 10)); + + return items($g->solve($ctx, $c), 'it0', 'it1', 'it2'); +}; + +// Overlay -- centered, and a corner badge. +$registry['overlay-center'] = static function (LayoutContext $ctx, Rect $c): array { + $o = Overlay::anchored('o') + ->add(Frame::preferred('base', 60, 60), Anchor::Center, Anchor::Center) + ->add(Frame::fixed('it0', 26, 26), Anchor::Center, Anchor::Center); + $s = $o->solve($ctx, $c); + + return [['rect' => frame($s, 'base'), 'accent' => false], ['rect' => frame($s, 'it0'), 'accent' => true]]; +}; +$registry['overlay-badge'] = static function (LayoutContext $ctx, Rect $c): array { + $o = Overlay::anchored('o')->padding(InsetSpec::px(6)) + ->add(Frame::preferred('base', 72, 72), Anchor::Center, Anchor::Center) + ->add(Frame::fixed('it0', 22, 22), Anchor::TopRight, Anchor::TopRight, offsetX: -6, offsetY: 6); + $s = $o->solve($ctx, $c); + + return [['rect' => frame($s, 'base'), 'accent' => false], ['rect' => frame($s, 'it0'), 'accent' => true]]; +}; + +// Spacer -- pushes items to opposite ends of a stack. +$registry['spacer'] = static function (LayoutContext $ctx, Rect $c): array { + $stack = Stack::horizontal('row')->align(Alignment::Center) + ->add(Frame::fixed('it0', 20, 40))->add(new Spacer('sp'))->add(Frame::fixed('it1', 20, 40)); + + return items($stack->solve($ctx, $c), 'it0', 'it1'); +}; + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- +$outDir = __DIR__.'/../docs/images'; +if (!is_dir($outDir) && !mkdir($outDir, 0o755, true) && !is_dir($outDir)) { + throw new RuntimeException('Cannot create output dir.'); +} + +$ok = 0; +$errors = []; +foreach ($registry as $id => $build) { + try { + $drawables = $build($ctx, $container); + file_put_contents($outDir.'/'.$id.'.svg', renderSvg($id, $container, $drawables)); + ++$ok; + } catch (Throwable $e) { + $errors[$id] = $e->getMessage(); + } +} + +echo "generated $ok / ".\count($registry)." illustrations into layout/docs/images/\n"; +foreach ($errors as $id => $msg) { + echo " FAILED $id: $msg\n"; +} + +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +/** @param list $drawables */ +function renderSvg(string $id, Rect $container, array $drawables): string +{ + $body = ['']; + foreach ($drawables as $d) { + $r = $d['rect']; + $body[] = ''; + } + + return ''."\n" + ." \n" + .' '.implode("\n ", $body)."\n" + .''."\n"; +} + +function num(float $v): string +{ + return rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.'); +} diff --git a/examples/smoke.php b/examples/smoke.php new file mode 100644 index 0000000..3dd302c --- /dev/null +++ b/examples/smoke.php @@ -0,0 +1,100 @@ +padding(InsetSpec::percent(4)) + ->gap(10) + ->alignItems(Alignment::Center) + ->distribute(Distribution::SpaceBetween) + ->add(Frame::fixed('left', 40, 20)) + ->add(Frame::fixed('middle', 30, 30)) + ->add(Frame::fixed('right', 50, 20)); + +$toolbarResult = $toolbar->solve($context, Rect::fromSize(200, 80)); + +$grid = Grid::columns('grid', 2) + ->rows([Atelier\Layout\Grid\TrackSize::fixed(32), Atelier\Layout\Grid\TrackSize::fr()]) + ->padding(InsetSpec::px(8)) + ->gap(12) + ->align(Alignment::Center, Alignment::Center) + ->add(TextBlock::of('a', 'First wrapped label', 12), columnSpan: 2) + ->add(Frame::fixed('b', 20, 20)) + ->add(Frame::fixed('c', 32, 16)) + ->add(TextBlock::of('d', 'Bottom item', 12), columnSpan: 2, alignX: Alignment::End); + +$gridResult = $grid->solve($context, new Rect(0, 0, 200, 120)); + +$group = Group::of('group') + ->add(Frame::fixed('badge', 80, 32)) + ->add(TextBlock::of('label', 'Centered text', 12)); + +$groupResult = $group->solve($context, Rect::fromSize(160, 90)); + +$flex = Stack::row('flex') + ->gap(4) + ->add(Frame::fixed('start', 20, 20)) + ->add(new Spacer('space')) + ->add(Frame::fixed('end', 20, 20)); + +$flexResult = $flex->solve($context, Rect::fromSize(100, 20)); +$bounds = Bounds::expand(Bounds::of(new Rect(10, 10, 20, 20), new Rect(40, 30, 10, 10)), Insets::all(4)); +$connection = (new OrthogonalConnector())->connect(new Rect(0, 0, 20, 20), new Rect(60, 20, 20, 20)); +$text = TextBlock::of('wrapped', 'Bottom snapped text', 12) + ->align(Alignment::Center, Alignment::End) + ->layout($context, new Rect(0, 0, 80, 48)); + +printf("toolbar.left=%s,%s,%s,%s\n", ...formatRectParts($toolbarResult->frameOf('left'))); +printf("toolbar.right=%s,%s,%s,%s\n", ...formatRectParts($toolbarResult->frameOf('right'))); +printf("grid.d=%s,%s,%s,%s\n", ...formatRectParts($gridResult->frameOf('d'))); +printf("group.label=%s,%s,%s,%s\n", ...formatRectParts($groupResult->frameOf('label'))); +printf("flex.space=%s,%s,%s,%s\n", ...formatRectParts($flexResult->frameOf('space'))); +printf("bounds=%s,%s,%s,%s\n", ...formatRectParts($bounds)); +printf("connection.points=%d label=%s,%s\n", \count($connection->points), number_format($connection->labelPoint->x, 2, '.', ''), number_format($connection->labelPoint->y, 2, '.', '')); +printf("text.lines=%d baseline=%s overflow=%s\n", \count($text->lines), number_format($text->lastBaseline() ?? 0.0, 2, '.', ''), $text->hasOverflow() ? 'yes' : 'no'); + +/** + * @return array{string, string, string, string} + */ +function formatRectParts(?Rect $rect): array +{ + if (null === $rect) { + return ['missing', 'missing', 'missing', 'missing']; + } + + return [ + number_format($rect->x, 2, '.', ''), + number_format($rect->y, 2, '.', ''), + number_format($rect->width, 2, '.', ''), + number_format($rect->height, 2, '.', ''), + ]; +} From 8c00076316a2639a1c82b103c69f41eb57d251d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Wed, 12 Aug 2026 16:43:38 +0200 Subject: [PATCH 5/7] assets: add the package logo set --- assets/logo/README.md | 35 ++++++++++++++++++++++++++++++++ assets/logo/lockup-h-dark.svg | 10 +++++++++ assets/logo/lockup-h-light.svg | 10 +++++++++ assets/logo/lockup-h.svg | 9 ++++++++ assets/logo/lockup-v-dark.svg | 9 ++++++++ assets/logo/lockup-v-light.svg | 9 ++++++++ assets/logo/lockup-v.svg | 8 ++++++++ assets/logo/mark-dark.svg | 6 ++++++ assets/logo/mark-light.svg | 6 ++++++ assets/logo/mark.svg | 5 +++++ assets/logo/wordmark-h-dark.svg | 7 +++++++ assets/logo/wordmark-h-light.svg | 7 +++++++ assets/logo/wordmark-h.svg | 6 ++++++ 13 files changed, 127 insertions(+) create mode 100644 assets/logo/README.md create mode 100644 assets/logo/lockup-h-dark.svg create mode 100644 assets/logo/lockup-h-light.svg create mode 100644 assets/logo/lockup-h.svg create mode 100644 assets/logo/lockup-v-dark.svg create mode 100644 assets/logo/lockup-v-light.svg create mode 100644 assets/logo/lockup-v.svg create mode 100644 assets/logo/mark-dark.svg create mode 100644 assets/logo/mark-light.svg create mode 100644 assets/logo/mark.svg create mode 100644 assets/logo/wordmark-h-dark.svg create mode 100644 assets/logo/wordmark-h-light.svg create mode 100644 assets/logo/wordmark-h.svg diff --git a/assets/logo/README.md b/assets/logo/README.md new file mode 100644 index 0000000..0e5ecd3 --- /dev/null +++ b/assets/logo/README.md @@ -0,0 +1,35 @@ +# Logo + +Lockups `ATELIER \ LAYOUT`, derives du logo Atelier. + +| Fichier | Boite | Contenu | +| --- | --- | --- | +| `lockup-h*.svg` | 660 x 180 | monogramme, wordmark, tagline, horizontal | +| `lockup-v*.svg` | 600 x 600 | monogramme au-dessus, wordmark, tagline | +| `wordmark-h*.svg` | 464 x 96 | wordmark seul | +| `mark*.svg` | 414 x 423 | monogramme seul | + +Le suffixe `-dark` porte un fond `#08090f`, `-light` un fond `#fdfaf3`. Sans suffixe, le +fond est transparent et le texte est calibre pour rester lisible sur clair comme sur sombre. + +## Usage + +Dans un README, servir la paire dark/light au theme du lecteur: + +```html + + + Atelier Layout + +``` + +Sur un fond dont vous ne maitrisez pas la couleur, utiliser la variante transparente +`lockup-h.svg`. + +## Contraintes + +- Ne pas recolorer le monogramme ni le gradient (`#00b0fc` vers `#5e4da1`). +- Ne pas separer le wordmark de sa barre oblique. +- Ne pas ajouter d'ombre portee. +- Le texte utilise `system-ui`: le rendu suit la machine du lecteur. Ne pas substituer + une webfont, ne pas convertir en ``. diff --git a/assets/logo/lockup-h-dark.svg b/assets/logo/lockup-h-dark.svg new file mode 100644 index 0000000..2756784 --- /dev/null +++ b/assets/logo/lockup-h-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + ATELIER \ LAYOUT + RENDERER-AGNOSTIC LAYOUT PRIMITIVES + diff --git a/assets/logo/lockup-h-light.svg b/assets/logo/lockup-h-light.svg new file mode 100644 index 0000000..906cc80 --- /dev/null +++ b/assets/logo/lockup-h-light.svg @@ -0,0 +1,10 @@ + + + + + + + + ATELIER \ LAYOUT + RENDERER-AGNOSTIC LAYOUT PRIMITIVES + diff --git a/assets/logo/lockup-h.svg b/assets/logo/lockup-h.svg new file mode 100644 index 0000000..44cda2c --- /dev/null +++ b/assets/logo/lockup-h.svg @@ -0,0 +1,9 @@ + + + + + + + ATELIER \ LAYOUT + RENDERER-AGNOSTIC LAYOUT PRIMITIVES + diff --git a/assets/logo/lockup-v-dark.svg b/assets/logo/lockup-v-dark.svg new file mode 100644 index 0000000..bd74260 --- /dev/null +++ b/assets/logo/lockup-v-dark.svg @@ -0,0 +1,9 @@ + + + + + + + ATELIER \ LAYOUT + RENDERER-AGNOSTIC LAYOUT PRIMITIVES + diff --git a/assets/logo/lockup-v-light.svg b/assets/logo/lockup-v-light.svg new file mode 100644 index 0000000..c464562 --- /dev/null +++ b/assets/logo/lockup-v-light.svg @@ -0,0 +1,9 @@ + + + + + + + ATELIER \ LAYOUT + RENDERER-AGNOSTIC LAYOUT PRIMITIVES + diff --git a/assets/logo/lockup-v.svg b/assets/logo/lockup-v.svg new file mode 100644 index 0000000..6cde62c --- /dev/null +++ b/assets/logo/lockup-v.svg @@ -0,0 +1,8 @@ + + + + + + ATELIER \ LAYOUT + RENDERER-AGNOSTIC LAYOUT PRIMITIVES + diff --git a/assets/logo/mark-dark.svg b/assets/logo/mark-dark.svg new file mode 100644 index 0000000..dffd9c9 --- /dev/null +++ b/assets/logo/mark-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/logo/mark-light.svg b/assets/logo/mark-light.svg new file mode 100644 index 0000000..dcb833c --- /dev/null +++ b/assets/logo/mark-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/logo/mark.svg b/assets/logo/mark.svg new file mode 100644 index 0000000..287eabb --- /dev/null +++ b/assets/logo/mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/logo/wordmark-h-dark.svg b/assets/logo/wordmark-h-dark.svg new file mode 100644 index 0000000..27a4c6e --- /dev/null +++ b/assets/logo/wordmark-h-dark.svg @@ -0,0 +1,7 @@ + + + + + + ATELIER \ LAYOUT + diff --git a/assets/logo/wordmark-h-light.svg b/assets/logo/wordmark-h-light.svg new file mode 100644 index 0000000..88e0c0f --- /dev/null +++ b/assets/logo/wordmark-h-light.svg @@ -0,0 +1,7 @@ + + + + + + ATELIER \ LAYOUT + diff --git a/assets/logo/wordmark-h.svg b/assets/logo/wordmark-h.svg new file mode 100644 index 0000000..31a235b --- /dev/null +++ b/assets/logo/wordmark-h.svg @@ -0,0 +1,6 @@ + + + + + ATELIER \ LAYOUT + From 57c83577d1d34065a2b2bf8da57bea395c59faf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Wed, 12 Aug 2026 16:43:38 +0200 Subject: [PATCH 6/7] test: require PHPUnit 13, fail on deprecations and notices --- composer.json | 2 +- phpunit.xml.dist | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 44dd7c7..f288744 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.94", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^12.5" + "phpunit/phpunit": "^13.1" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 079b478..e54962e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,10 @@ beStrictAboutCoverageMetadata="false" beStrictAboutOutputDuringTests="true" failOnRisky="true" - failOnWarning="true"> + failOnWarning="true" + failOnDeprecation="true" + failOnNotice="true" + failOnEmptyTestSuite="true"> From 2f3660e3ffa2246aab273269eb313710d7f47db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Thu, 13 Aug 2026 05:45:35 +0200 Subject: [PATCH 7/7] fix: keep PHPUnit 12.5, which still runs on PHP 8.3 PHPUnit 13 requires php >= 8.4.1, so requiring it here made composer install insoluble on the 8.3 job: cs, sa and tests all died before running anything. The package supports PHP 8.3 and the matrix tests it. The strictness that came with the bump stays. failOnDeprecation, failOnNotice and failOnEmptyTestSuite are all in the 12.5 schema, so nothing is lost by holding the version back. --- README.md | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1153d1c..d035c69 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

PHP Version Tests - PHPUnit + PHPUnit PHPStan Stable License diff --git a/composer.json b/composer.json index f288744..44dd7c7 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.94", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^13.1" + "phpunit/phpunit": "^12.5" }, "autoload": { "psr-4": {