Skip to content

Commit

Permalink
plot: Plot Annotations (#336)
Browse files Browse the repository at this point in the history
Implements #136 partly (in plot coordinates).

This PR adds `add-annotation` to allow drawing in plots. The plot also
gets resized to the drawing if it exceeds the axis bounds.

In addition, it fixes `place-anchors`, which raised an error because
elements are not dictionaries anymore.
  • Loading branch information
johannes-wolf authored Dec 3, 2023
1 parent 3a1705d commit 40477d2
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 19 deletions.
Binary file modified manual.pdf
Binary file not shown.
1 change: 1 addition & 0 deletions manual.typ
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ plot.plot(
#doc-style.parse-show-module("/src/lib/plot/line.typ")
#doc-style.parse-show-module("/src/lib/plot/contour.typ")
#doc-style.parse-show-module("/src/lib/plot/boxwhisker.typ")
#doc-style.parse-show-module("/src/lib/plot/annotation.typ")
#doc-style.parse-show-module("/src/lib/plot/sample.typ")

=== Examples
Expand Down
43 changes: 28 additions & 15 deletions src/draw/grouping.typ
Original file line number Diff line number Diff line change
Expand Up @@ -245,21 +245,25 @@
},)
}

/// TODO: Not writing the docs for this as it should be removed in place of better anchors before 0.2
/// Place multiple anchors along a path
/// Place multiple anchors along a path.
///
/// *DEPRECATED*
///
/// #example(```
/// place-anchors(circle(()), "circle", ("a", 0), ("b", .5), ("c", .75))
/// for-each-anchor("circle", n => {
/// circle("circle." + n, radius: .1, fill: blue, stroke: none)
/// })
/// ```)
///
/// - path (drawable): Single drawable
/// - ..anchors (array): List of anchor dictionaries of the form `(pos: <float>, name: <string>)`, where
/// `pos` is a relative position on the path from `0` to `1`.
/// - name: (auto,string): If auto, take the name of the passed drawable. Otherwise sets the
/// elements name
#let place-anchors(path, ..anchors, name: auto) = {
let name = if name == auto and "name" in path.first() {
path.first().name
} else {
name
}
assert(type(name) == str, message: "Name must be of type string")
/// - name: (string): The grouping elements name
/// - ..anchors (array): List of anchor tuples `(name, pos)` or dictionaries of the
/// form `(name: <string>, pos: <float, ratio>)`, where `pos` is a relative position
/// on the path from `0` to `1` or 0% to 100%.
#let place-anchors(path, name, ..anchors) = {
assert(type(name) == str,
message: "Name must be of type string, got: " + type(name))

return (ctx => {
let (ctx, drawables) = process.many(ctx, path)
Expand All @@ -269,8 +273,17 @@

let out = (:)
for a in anchors.pos() {
assert("name" in a, message: "Anchor must have a name set")
out.insert(a.name, path-util.point-on-path(s, a.pos))
assert(type(a) in (dictionary, array),
message: "Expected anchor tuple or dictionary, got: " + repr(a))
let (name, pos) = if type(a) == dictionary {
(a.name, a.pos)
} else {
a
}
if type(pos) == ratio {
pos /= 100%
}
out.insert(name, path-util.point-on-path(s, pos))
}

return (
Expand Down
42 changes: 39 additions & 3 deletions src/lib/plot.typ
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#import "plot/boxwhisker.typ": add-boxwhisker
#import "plot/util.typ" as plot-util
#import "plot/legend.typ" as plot-legend
#import "plot/annotation.typ": annotate, calc-annotation-domain
#import "plot/mark.typ"

#let default-colors = (blue, red, green, yellow, black)
Expand All @@ -39,7 +40,7 @@
/// ```)
///
/// To draw elements insides a plot, using the plots coordinate system, use
/// the `plot.add-annotation(..)` function.
/// the `plot.annotate(..)` function.
///
/// = parameters
///
Expand Down Expand Up @@ -135,7 +136,7 @@
///
/// - body (body): Calls of `plot.add` or `plot.add-*` commands. Note that normal drawing
/// commands like `line` or `rect` are not allowed inside the plots body, instead wrap
/// them in `plot.add-annotation`, which lets you select the axes used for drawing.
/// them in `plot.annotate`, which lets you select the axes used for drawing.
/// - size (array): Plot size tuple of `(<width>, <height>)` in canvas units.
/// This is the plots inner plotting size without axes and labels.
/// - axis-style (none, string): How the axes should be styled:
Expand Down Expand Up @@ -230,12 +231,17 @@

let data = ()
let anchors = ()
let annotations = ()
let body = if body != none { body } else { () }

for cmd in body {
assert(type(cmd) == dictionary and "type" in cmd,
message: "Expected plot sub-command in plot body")
if cmd.type == "anchor" { anchors.push(cmd) } else { data.push(cmd) }
if cmd.type == "anchor" {
anchors.push(cmd)
} else if cmd.type == "annotation" {
annotations.push(cmd)
} else { data.push(cmd) }
}

assert(axis-style in (none, "scientific", "school-book", "left"),
Expand Down Expand Up @@ -273,6 +279,14 @@
}
}

// Adjust axis bounds for annotations
for a in annotations {
let (x, y) = a.axes.map(name => axis-dict.at(name))
(x, y) = calc-annotation-domain(ctx, x, y, a)
axis-dict.at(a.axes.at(0)) = x
axis-dict.at(a.axes.at(1)) = y
}

// Set axis options
axis-dict = plot-util.setup-axes(axis-dict, options.named(), size)

Expand Down Expand Up @@ -316,6 +330,17 @@
}
}

// Background Annotations
for a in annotations.filter(a => a.background) {
let (x, y) = a.axes.map(name => axis-dict.at(name))
let plot-ctx = make-ctx(x, y, size)

data-viewport(a, x, y, size, {
draw.anchor("center", (0, 0))
a.body
})
}

// Fill
if fill-below {
for d in data {
Expand Down Expand Up @@ -376,6 +401,17 @@
})
}

// Foreground Annotations
for a in annotations.filter(a => not a.background) {
let (x, y) = a.axes.map(name => axis-dict.at(name))
let plot-ctx = make-ctx(x, y, size)

data-viewport(a, x, y, size, {
draw.anchor("center", (0, 0))
a.body
})
}

// Place anchors
for a in anchors {
let (x, y) = a.axes.map(name => axis-dict.at(name))
Expand Down
75 changes: 75 additions & 0 deletions src/lib/plot/annotation.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#import "util.typ"
#import "sample.typ"
#import "/src/draw.typ"
#import "/src/process.typ"
#import "/src/util.typ"

/// Add an annotation to the plot
///
/// An annotation is a sub-canvas that uses the plots coordinates specified
/// by its x and y axis.
///
/// #example(```
/// import cetz.plot
/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
/// plot.add(domain: (0, 2*calc.pi), calc.sin)
/// plot.annotate({
/// rect((0, -1), (calc.pi, 1), fill: rgb(50,50,200,50))
/// content((calc.pi, 0), [Here])
/// })
/// })
/// ```)
///
/// Bounds calculation is done naively, therefore fixed size content _can_ grow
/// out of the plot. You can adjust the padding manually to adjust for that. The
/// feature of solving the correct bounds for fixed size elements might be added
/// in the future.
///
/// - body (drawable): Elements to draw
/// - axes (axes): X and Y axis names
/// - resize (bool): If true, the plots axes get adjusted to contain the annotation
/// - padding (none,number,dictionary): Annotation padding that is used for axis
/// adjustment
/// - background (bool): If true, the annotation is drawn behind all plots, in the background.
/// If false, the annotation is drawn above all plots.
#let annotate(body, axes: ("x", "y"), resize: true, padding: none, background: false) = {
((
type: "annotation",
body: body,
axes: axes,
resize: resize,
background: background,
padding: util.as-padding-dict(padding),
),)
}

// Returns the adjusted axes for the annotation object
//
// -> array Tuple of x and y axis
#let calc-annotation-domain(ctx, x, y, annotation) = {
if not annotation.resize {
return (x, y)
}

let (ctx: ctx, bounds: bounds, drawables: _) = process.many(ctx, annotation.body)
if bounds == none {
return (x, y)
}

let (x-min, y-max, ..) = bounds.low
y-max *= -1
let (x-max, y-min, ..) = bounds.high
y-min *= -1

x-min -= annotation.padding.left
x-max += annotation.padding.right
y-min -= annotation.padding.bottom
y-max += annotation.padding.top

x.min = calc.min(x.min, x-min)
x.max = calc.max(x.max, x-max)
y.min = calc.min(y.min, y-min)
y.max = calc.max(y.max, y-max)

return (x, y)
}
2 changes: 1 addition & 1 deletion tests/anchor-on-path/test.typ
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

let place-along = (path) => {
let name = "obj"
place-anchors(path, name: name,
place-anchors(path, name,
..range(0, 11).map(x => (name: str(x), pos: x / 10)))

for x in range(0, 11) {
Expand Down
Binary file added tests/plot/annotation/ref.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions tests/plot/annotation/test.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#set page(width: auto, height: auto)
#import "/src/lib.typ": *

#box(stroke: 2pt + red, canvas({
import draw: *
set-style(rect: (stroke: none))

plot.plot(size: (6, 4), {
plot.add(domain: (-calc.pi, 3*calc.pi), calc.sin)
plot.annotate(background: true, {
rect((0, -1), (calc.pi, 1), fill: blue.lighten(90%))
rect((calc.pi, -1.1), (2*calc.pi, 1.1), fill: red.lighten(90%))
rect((2*calc.pi, -1.5), (3.5*calc.pi, 1.5), fill: green.lighten(90%))
})
plot.annotate(padding: .1, {
line((calc.pi / 2, 1.1), (rel: (0, .2)), (rel: (2*calc.pi, 0)), (rel: (0, -.2)))
content((calc.pi * 1.5, 1.5), $ lambda $)
})
})
}))

0 comments on commit 40477d2

Please sign in to comment.