diff --git a/src/axis.typ b/src/axis.typ index a5b697f..3d22444 100644 --- a/src/axis.typ +++ b/src/axis.typ @@ -1,9 +1,11 @@ +#import "/src/ticks.typ" +#import "/src/plot/util.typ" /// Transform linear axis value to linear space (low, high) #let _transform-lin(ax, value, low, high) = { let range = high - low - return (value - ax.low) * (range / (ax.high - ax.low)) + return low + (value - ax.min) * (range / (ax.max - ax.min)) } /// Transform log axis value to linear space (low, high) @@ -14,17 +16,37 @@ calc.log(calc.max(x, util.float-epsilon), base: ax.base) } - return (value - f(ax.low)) * (range / (f(ax.high) - f(ax.low))) + return low + (f(value) - f(ax.min)) * (range / (f(ax.max) - f(ax.min))) } -#let linear(low, high) = ( - low: low, high: high, transform: _transform-lin, +/// Linear Axis Constructor +#let linear(name, min, max) = ( + label: [#name], + name: name, min: min, max: max, base: 10, transform: _transform-lin, + auto-domain: (none, none), + ticks: (step: auto, minor-step: none, format: auto, list: none), + grid: none, + compute-ticks: ticks.compute-ticks.with("lin"), ) -#let logarithmic(low, high, base) = ( - low: low, high: high, base: base, transform: _transform-log, +/// Log Axis Constructor +#let logarithmic(name, min, max, base) = ( + label: [#name], + name: name, min: min, max: max, base: base, transform: _transform-log, + auto-domain: (none, none), + ticks: (step: auto, minor-step: none, format: auto, list: none), + grid: none, + compute-ticks: ticks.compute-ticks.with("log"), ) +// Prepare axis +#let prepare(ptx, ax) = { + if ax.min == none { ax.min = ax.auto-domain.at(0) } + if ax.max == none { ax.max = ax.auto-domain.at(1) } + if "compute-ticks" in ax { ax.computed-ticks = (ax.compute-ticks)(ax) } + return ax +} + /// Transform an axis value to a linear value between low and high /// - ax (axis): Axis /// - value (number): Value to transform from axis space to linear space diff --git a/src/plot.typ b/src/plot.typ index 78451f4..1a4faf8 100644 --- a/src/plot.typ +++ b/src/plot.typ @@ -1,6 +1,12 @@ -#import "/src/cetz.typ": util, draw, matrix, vector, styles, palette +#import "/src/cetz.typ" +#import cetz: util, draw, matrix, vector, styles, palette, coordinate, styles #import util: bezier +#import "/src/axis.typ" +#import "/src/projection.typ" +#import "/src/ticks.typ" +#import "/src/spine.typ": * + #import "/src/axes.typ" #import "/src/plot/sample.typ": sample-fn, sample-fn2 #import "/src/plot/line.typ": add, add-hline, add-vline, add-fill-between @@ -29,23 +35,43 @@ return default-plot-style(i) } -/// Add a cartesian axis to a plot -#let add-cartesian-axis(name, origin, target) = { - ((type: "context", fn: (ctx) => { - let axis = ( - name: name, - kind: "cartesian", - origin: origin, - target: target, - min: none, - max: none, - ticks: (step: auto, minor-step: none, format: "float", list: ()) - ) - ctx.axes.insert(name, axis) - return ctx +/// Add a linear axis to a plot +/// - name (str): Axis name +/// - min: (none, float): Minimum +/// - max: (none, float): Maximum +#let lin-axis(name, min: none, max: none) = { + ((priority: -100, fn: (ptx) => { + ptx.axes.insert(name, axis.linear(name, min, max)) + return ptx + }),) +} + +/// Add a logarithmic axis to a plot +/// - name (str): Axis name +/// - min: (none, float): Minimum +/// - max: (none, float): Maximum +/// - base: (int): Log base +#let log-axis(name, min: none, max: none, base: 10) = { + ((priority: -100, fn: (ptx) => { + ptx.axes.insert(name, axis.logarithmic(name, min, max, base)) + return ptx }),) } + +#let templates = ( + scientific: (ptx) => { + lin-axis("x") + lin-axis("y") + cartesian-scientific("x", "y") + }, + school-book: (ptx) => { + lin-axis("x") + lin-axis("y") + cartesian-schoolbook("x", "y") + }, +) + /// Create a plot environment. Data to be plotted is given by passing it to the /// `plot.add` or other plotting functions. The plot environment supports different /// axis styles to draw, see its parameter `axis-style:`. @@ -204,303 +230,124 @@ /// - legend-style (style): Style key-value overwrites for the legend style with style root `legend`. /// - ..options (any): Axis options, see _options_ below. #let plot(body, - size: (1, 1), - axis-style: "scientific", name: none, + size: (5, 4), + template: "scientific", plot-style: default-plot-style, - mark-style: default-mark-style, - fill-below: true, legend: auto, - legend-anchor: auto, + draw-legend: plot-legend.draw-legend, legend-style: (:), - ..options - ) = draw.group(name: name, cetz-ctx => { - draw.assert-version(version(0, 3, 1)) - - // Plot local context - let ctx = ( - origin: (0, 0), - size: size, - axes: (:) - ) - - // Setup default axes - let default-axes = ( - if axis-style in (none, "scientific", "scientific-auto") { - add-cartesian-axis("x", (0,0), (size.at(0),0)) - add-cartesian-axis("x2", (0,size.at(1)), (size.at(0),size.at(1))) - add-cartesian-axis("y", (0,0), (0,size.at(1))) - add-cartesian-axis("y2", (size.at(0),0), (size.at(0),size.at(1))) - } else if axis-style in ("school-book", "left") { - add-cartesian-axis("x", (0,0), (size.at(0),0)) - add-cartesian-axis("y", (0,0), (0,size.at(1))) - } + fill-below: true, + ..options) = draw.get-ctx(ctx => { + let body = body + let ptx = ( + cetz-ctx: ctx, + + default-size: size, + options: options.named(), + + axes: (:), + plots: (), + spines: (), + data: (), + legend: (), + anchors: (), ) - let body = default-axes + body - - // Create plot context object - let make-ctx(axes, size) = { - assert(axes.len() >= 1, message: "At least one axis must exist") - assert(size.at(0) > 0 and size.at(1) > 0, message: "Plot size must be > 0") - - return (axes: axes, size: size) + if template != none and template in templates { + body += (templates.at(template))(ptx) } - // Setup data viewport - let data-viewport(data, all-axes, body, name: none) = { - if body == none or body == () { return } + let plot-elements = body + .filter(elem => type(elem) == dictionary) + .sorted(key: elem => elem.at("priority", default: 0)) + let cetz-elements = body + .filter(elem => type(elem) == function) - // Setup the viewport - axes.axis-viewport(all-axes, body, name: name) - } + // Create axes + //ptx = plot-util.create-axes(ptx, plot-elements, options.named()) - let data = () - let anchors = () - let annotations = () - let body = if body != none { body } else { () } + for elem in plot-elements.filter(elem => elem.priority <= 0) { + assert("fn" in elem, + message: "Invalid plot element: " + repr(elem)) - 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 if cmd.type == "annotation" { - annotations.push(cmd) - } else if cmd.type == "context" { - ctx = (cmd.fn)(ctx) - } else { data.push(cmd) } + ptx = (elem.fn)(ptx) + assert(ptx != none) } - assert(axis-style in (none, "scientific", "scientific-auto", "school-book", "left"), - message: "Invalid plot style") - + // Apply axis options & prepare axes + ptx = plot-util.setup-axes(ptx, options.named()) - // Create axes for data & annotations - for d in data + annotations { - if "axes" not in d { continue } - - for (i, name) in d.axes.enumerate() { - assert(name in ctx.axes, message: "Undefined axis " + name) - - let axis = ctx.axes.at(name) - axis.used = true - - let domain = if i == 0 { - d.at("x-domain", default: (none, none)) - } else { - d.at("y-domain", default: (none, none)) - } - if domain != (none, none) { - axis.min = util.min(axis.min, ..domain) - axis.max = util.max(axis.max, ..domain) - } + for elem in plot-elements.filter(elem => elem.priority > 0) { + assert("fn" in elem, + message: "Invalid plot element: " + repr(elem)) - ctx.axes.at(name) = axis - } + ptx = (elem.fn)(ptx) + assert(ptx != none) } - // Set axis options - ctx.axes = plot-util.setup-axes(cetz-ctx, ctx.axes, options.named(), size) - // Prepare styles - for i in range(data.len()) { - if "style" not in data.at(i) { continue } - - let style-base = plot-style - if type(style-base) == function { - style-base = (style-base)(i) - } - assert.eq(type(style-base), dictionary, - message: "plot-style must be of type dictionary") - - if type(data.at(i).style) == function { - data.at(i).style = (data.at(i).style)(i) - } - assert.eq(type(style-base), dictionary, - message: "data plot-style must be of type dictionary") - - data.at(i).style = util.merge-dictionary( - style-base, data.at(i).style) - - if "mark-style" in data.at(i) { - let mark-style-base = mark-style - if type(mark-style-base) == function { - mark-style-base = (mark-style-base)(i) - } - assert.eq(type(mark-style-base), dictionary, - message: "mark-style must be of type dictionary") - - if type(data.at(i).mark-style) == function { - data.at(i).mark-style = (data.at(i).mark-style)(i) - } - - if type(data.at(i).mark-style) == dictionary { - data.at(i).mark-style = util.merge-dictionary( - mark-style-base, - data.at(i).mark-style - ) - } - } - } - - draw.group(name: "plot", { - draw.anchor("origin", (0, 0)) - - // Prepare - for i in range(data.len()) { - if "axes" not in data.at(i) { continue } - - let plot-ctx = make-ctx(data.at(i).axes.map(name => ctx.axes.at(name)), size) - - if "plot-prepare" in data.at(i) { - data.at(i) = (data.at(i).plot-prepare)(data.at(i), plot-ctx) - assert(data.at(i) != none, - message: "Plot prepare(self, cxt) returned none!") - } + ptx.data = ptx.data.enumerate().map(((i, data)) => { + let style = if type(plot-style) == function { + (plot-style)(i) + } else if type(plot-style) == array { + plot-style.at(i) + } else { + plot-style } - // Background Annotations - for a in annotations.filter(a => a.background) { - let plot-ctx = make-ctx(a.axes.map(name => ctx.axes.at(name)), size) - - data-viewport(a, plot-ctx.axes, { - draw.anchor("default", (0, 0)) - a.body - }) - } - - // Fill - if fill-below { - for d in data { - if "axes" not in d { continue } - - let plot-ctx = make-ctx(d.axes.map(name => ctx.axes.at(name)), size) - - data-viewport(d, plot-ctx.axes, { - draw.anchor("default", (0, 0)) - draw.set-style(..d.style) - - if "plot-fill" in d { - (d.plot-fill)(d, plot-ctx) - } - }) - } - } - - if axis-style in ("scientific", "scientific-auto") { - let draw-unset = if axis-style == "scientific" { - true - } else { - false - } - - let mirror = if axis-style == "scientific" { - auto - } else { - none - } - - axes.scientific( - size: size, - draw-unset: draw-unset, - bottom: ctx.axes.at("x", default: none), - top: ctx.axes.at("x2", default: mirror), - left: ctx.axes.at("y", default: none), - right: ctx.axes.at("y2", default: mirror),) - } else if axis-style == "left" { - axes.school-book( - size: size, - ctx.axes.x, - ctx.axes.y, - x-position: ctx.axes.y.min, - y-position: ctx.axes.x.min) - } else if axis-style == "school-book" { - axes.school-book( - size: size, - ctx.axes.x, - ctx.axes.y,) - } - - // Stroke + Mark data - for d in data { - if "axes" not in d { continue } - - let plot-ctx = make-ctx(d.axes.map(name => ctx.axes.at(name)), size) - - data-viewport(d, plot-ctx.axes, { - draw.anchor("default", (0, 0)) - draw.set-style(..d.style) + data.style = cetz.util.merge-dictionary(style, data.at("style", default: (:))) + return data + }) - if not fill-below and "plot-fill" in d { - (d.plot-fill)(d, plot-ctx) + draw.group(name: name, { + draw.group(name: "plot", { + for sub-plot in ptx.plots { + let sub-data = ptx.data.filter(data => data.axes.all(ax => ax in sub-plot.axes)) + for data in sub-data { + draw.scope({ + draw.set-style(..data.style) + if fill-below { + (data.fill)(ptx, sub-plot.proj) + } + }) } - if "plot-stroke" in d { - (d.plot-stroke)(d, plot-ctx) + for spine in ptx.spines.filter(spine => spine.axes.all(ax => ax in sub-plot.axes)) { + draw.group(name: spine.name, { + (spine.draw)(ptx, sub-plot.proj) + }) + } + for data in sub-data { + draw.scope({ + draw.set-style(..data.style) + if not fill-below { + (data.fill)(ptx, sub-plot.proj) + } + (data.stroke)(ptx, sub-plot.proj) + }) } - }) - - if "mark" in d and d.mark != none { - draw.scope({ - draw.set-style(..d.style, ..d.mark-style) - mark.draw-mark(d.data, plot-ctx.axes, d.mark, d.mark-size) - }) } - } - - // Foreground Annotations - for a in annotations.filter(a => not a.background) { - let plot-ctx = make-ctx(a.axes.map(name => ctx.axes.at(name)), size) - - data-viewport(a, plot-ctx.axes, { - draw.anchor("default", (0, 0)) - a.body + draw.scope({ + cetz-elements }) - } - - // Place anchors - for a in anchors { - let plot-ctx = make-ctx(a.axes.map(name => ctx.axes.at(name)), size) - - let pt = a.position.enumerate().map(((i, v)) => { - if v == "min" { return plot-ctx.axes.at(i).min } - if v == "max" { return plot-ctx.axes.at(i).max } - return v + }) + + if ptx.legend != none { + draw.scope({ + draw.set-origin("plot." + options.at("legend", default: "north-east")) + draw.group(name: "legend", anchor: options.at("legend-anchor", default: "north-west"), { + draw.anchor("default", (0,0)) + draw-legend(ptx) + }) }) - pt = axes.transform-vec(plot-ctx.axes, pt) - if pt != none { - draw.anchor(a.name, pt) - } } - }) - - // Draw the legend - if legend != none { - let items = data.filter(d => "label" in d and d.label != none) - if items.len() > 0 { - let legend-style = styles.resolve(cetz-ctx.style, - base: plot-legend.default-style, merge: legend-style, root: "legend") - plot-legend.add-legend-anchors(legend-style, "plot", size) - plot-legend.legend(legend, anchor: legend-anchor, { - for item in items { - let preview = if "plot-legend-preview" in item { - _ => {(item.plot-legend-preview)(item) } - } else { - auto - } - - plot-legend.item(item.label, preview, - mark: item.at("mark", default: none), - mark-size: item.at("mark-size", default: none), - mark-style: item.at("mark-style", default: none), - ..item.at("style", default: (:))) - } - }, ..legend-style) + for (name, pt) in ptx.anchors { + draw.anchor(name, pt) } - } - draw.copy-anchors("plot") + draw.copy-anchors("plot") + }) }) /// Add an anchor to a plot environment @@ -529,9 +376,15 @@ /// as `add-anchors` does not create them on demand. #let add-anchor(name, position, axes: ("x", "y")) = { (( - type: "anchor", - name: name, - position: position, - axes: axes, + priority: 100, + fn: ptx => { + for plot in ptx.plots { + if plot.axes.filter(name => name != none) == axes { + let pt = (plot.proj)(position).first() + ptx.anchors.push((name, pt)) + } + } + return ptx + } ),) } diff --git a/src/plot/legend.typ b/src/plot/legend.typ index 475209b..859504b 100644 --- a/src/plot/legend.typ +++ b/src/plot/legend.typ @@ -240,3 +240,8 @@ (plot-legend-preview: _ => { preview() }) },) } + +// TODO: Stub +#let draw-legend(ptx) = { + draw.rect((0,0), (1,1)) +} diff --git a/src/plot/line.typ b/src/plot/line.typ index c709281..8fcc440 100644 --- a/src/plot/line.typ +++ b/src/plot/line.typ @@ -1,4 +1,4 @@ -#import "/src/cetz.typ": draw +#import "/src/cetz.typ": canvas, draw #import "util.typ" #import "sample.typ" @@ -7,7 +7,7 @@ // // - data (array): Data points // - line (str,dictionary): Line line -#let transform-lines(data, line) = { +#let _transform-lines(data, line) = { let hvh-data(t) = { if type(t) == ratio { t = t / 1% @@ -48,8 +48,8 @@ return util.linearized-data(data, line.at("epsilon", default: 0)) } else if line-type == "spline" { return util.sampled-spline-data(data, - line.at("tension", default: .5), - line.at("samples", default: 15)) + line.at("tension", default: .5), + line.at("samples", default: 15)) } else if line-type == "vh" { return hvh-data(0) } else if line-type == "hv" { @@ -62,7 +62,7 @@ } // Fill a plot by generating a fill path to y value `to` -#let fill-segments-to(segments, to) = { +#let _fill-segments-to(segments, to) = { for s in segments { let low = calc.min(..s.map(v => v.at(0))) let high = calc.max(..s.map(v => v.at(0))) @@ -75,55 +75,12 @@ } // Fill a shape by generating a fill path for each segment -#let fill-shape(paths) = { +#let _fill-shape(paths) = { for p in paths { draw.line(..p, stroke: none) } } -// Prepare line data -#let _prepare(self, ctx) = { - // Generate stroke paths - self.stroke-paths = util.compute-stroke-paths(self.line-data, ctx.axes) - - // Compute fill paths if filling is requested - self.hypograph = self.at("hypograph", default: false) - self.epigraph = self.at("epigraph", default: false) - self.fill = self.at("fill", default: false) - if self.hypograph or self.epigraph or self.fill { - self.fill-paths = util.compute-fill-paths(self.line-data, ctx.axes) - } - - return self -} - -// Stroke line data -#let _stroke(self, ctx) = { - for p in self.stroke-paths { - draw.line(..p, fill: none) - } -} - -// Fill line data -#let _fill(self, ctx) = { - let (x, y, ..) = ctx.axes - - if self.hypograph { - fill-segments-to(self.fill-paths, y.min) - } - if self.epigraph { - fill-segments-to(self.fill-paths, y.max) - } - if self.fill { - if self.at("fill-type", default: "axis") == "shape" { - fill-shape(self.fill-paths) - } else { - fill-segments-to(self.fill-paths, - calc.max(calc.min(y.max, 0), y.min)) - } - } -} - /// Add data to a plot environment. /// /// Note: You can use this for scatter plots by setting @@ -219,7 +176,7 @@ } // Transform data - let line-data = transform-lines(data, line) + let line-data = _transform-lines(data, line) // Get x-domain let x-domain = ( @@ -233,31 +190,54 @@ calc.max(..line-data.map(t => t.at(1))) )} - (( - type: "line", - label: label, - data: data, /* Raw data */ - line-data: line-data, /* Transformed data */ - axes: axes, - x-domain: x-domain, - y-domain: y-domain, - epigraph: epigraph, - hypograph: hypograph, - fill: fill, - fill-type: fill-type, - style: style, - mark: mark, - mark-size: mark-size, - mark-style: mark-style, - plot-prepare: _prepare, - plot-stroke: _stroke, - plot-fill: _fill, - plot-legend-preview: self => { - if self.fill or self.epigraph or self.hypograph { - draw.rect((0,0), (1,1), ..self.style) - } else { - draw.line((0,.5), (1,.5), ..self.style) - } + return (( + priority: 0, + fn: ptx => { + ptx = util.set-auto-domain(ptx, axes, (x-domain, y-domain)) + + ptx.data.push(( + label: label, + axes: axes, + fill: (ptx, proj) => { + let (x, y) = axes.map(name => ptx.axes.at(name)) + + if hypograph or epigraph or fill { + let (min-y, max-y) = proj((0, y.min), (0, y.max)).map(v => v.at(1)) + let fill-paths = util.compute-fill-paths(line-data, (x, y)) + .map(path => proj(..path)) + if hypograph { + _fill-segments-to(fill-paths, min-y) + } + if epigraph { + _fill-segments-to(fill-paths, max-y) + } + if fill { + if fill-type == "shape" { + _fill-shape(fill-paths) + } else { + _fill-segments-to(fill-paths, + calc.max(calc.min(max-y, 0), min-y)) + } + } + } + }, + stroke: (ptx, proj) => { + let (x, y) = axes.map(name => ptx.axes.at(name)) + + let stroke-paths = util.compute-stroke-paths(line-data, (x, y)) + .map(path => proj(..path)) + for path in stroke-paths { + draw.line(..path, fill: none) + } + }, + preview: () => { + // TODO + draw.rect((0,0), (2,1.5)) + }, + style: style, + )) + + return ptx } ),) } @@ -290,37 +270,33 @@ message: "Specify at least one y value") assert(y.named().len() == 0) - let prepare(self, ctx) = { - let (x, y, ..) = ctx.axes - let (x-min, x-max) = (x.min, x.max) - let (y-min, y-max) = (y.min, y.max) - let x-min = if min == auto { x-min } else { min } - let x-max = if max == auto { x-max } else { max } - - self.lines = self.y.filter(y => y >= y-min and y <= y-max) - .map(y => ((x-min, y), (x-max, y))) - return self - } - - let stroke(self, ctx) = { - for (a, b) in self.lines { - draw.line(a, b, fill: none) + return (( + priority: 0, + fn: ptx => { + let pts = y.pos() + + ptx.data.push(( + label: label, + axes: axes, + fill: (ptx, proj) => { + }, + stroke: (ptx, proj) => { + let (x, y) = axes.map(name => ptx.axes.at(name)) + + let min = if min == auto { x.min } else { min } + let max = if max == auto { x.max } else { max } + for pt in pts.filter(v => y.min <= v and v <= y.max) { + draw.line(..proj((min, pt), (max, pt))) + } + }, + preview: () => { + // TODO + }, + style: style, + )) + + return ptx } - } - - let x-min = if min == auto { none } else { min } - let x-max = if max == auto { none } else { max } - - (( - type: "hline", - label: label, - y: y.pos(), - x-domain: (x-min, x-max), - y-domain: (calc.min(..y.pos()), calc.max(..y.pos())), - axes: axes, - style: style, - plot-prepare: prepare, - plot-stroke: stroke, ),) } @@ -353,37 +329,33 @@ message: "Specify at least one x value") assert(x.named().len() == 0) - let prepare(self, ctx) = { - let (x, y, ..) = ctx.axes - let (x-min, x-max) = (x.min, x.max) - let (y-min, y-max) = (y.min, y.max) - let y-min = if min == auto { y-min } else { min } - let y-max = if max == auto { y-max } else { max } - - self.lines = self.x.filter(x => x >= x-min and x <= x-max) - .map(x => ((x, y-min), (x, y-max))) - return self - } - - let stroke(self, ctx) = { - for (a, b) in self.lines { - draw.line(a, b, fill: none) + return (( + priority: 0, + fn: ptx => { + let pts = x.pos() + + ptx.data.push(( + label: label, + axes: axes, + fill: (ptx, proj) => { + }, + stroke: (ptx, proj) => { + let (x, y) = axes.map(name => ptx.axes.at(name)) + + let min = if min == auto { y.min } else { min } + let max = if max == auto { y.max } else { max } + for pt in pts.filter(v => x.min <= v and v <= x.max) { + draw.line(..proj((pt, min), (pt, max))) + } + }, + preview: () => { + // TODO + }, + style: style, + )) + + return ptx } - } - - let y-min = if min == auto { none } else { min } - let y-max = if max == auto { none } else { max } - - (( - type: "vline", - label: label, - x: x.pos(), - x-domain: (calc.min(..x.pos()), calc.max(..x.pos())), - y-domain: (y-min, y-max), - axes: axes, - style: style, - plot-prepare: prepare, - plot-stroke: stroke ),) } @@ -433,8 +405,8 @@ } // Transform data - let line-a-data = transform-lines(data-a, line) - let line-b-data = transform-lines(data-b, line) + let line-a-data = _transform-lines(data-a, line) + let line-b-data = _transform-lines(data-b, line) // Get x-domain let x-domain = ( @@ -475,7 +447,7 @@ } let fill(self, ctx) = { - fill-shape(self.fill-paths) + _fill-shape(self.fill-paths) } (( diff --git a/src/plot/util.typ b/src/plot/util.typ index 70b5057..0bec863 100644 --- a/src/plot/util.typ +++ b/src/plot/util.typ @@ -2,6 +2,29 @@ #import cetz.util: bezier #import cetz: vector +#let float-epsilon = cetz.util.float-epsilon + +#let set-auto-domain(ptx, axes, dom) = { + let axes = axes.map(name => ptx.axes.at(name)) + assert(axes.len() == dom.len()) + + for (i, ax) in axes.enumerate() { + let (min, max) = ax.auto-domain + if min == none { + min = dom.at(i).at(0) + } else { + min = calc.min(min, dom.at(i).at(0)) + } + if max == none { + max = dom.at(i).at(1) + } else { + max = calc.max(max, dom.at(i).at(1)) + } + ptx.axes.at(ax.name).auto-domain = (min, max) + } + return ptx +} + /// Clip line-strip in rect /// /// - points (array): Array of vectors representing a line-strip @@ -275,105 +298,71 @@ return lower(name).starts-with("x") } +// Create axes specified by options +#let create-axes(ptx, elements, options) = { + import "/src/axis.typ" + for element in elements { + if "axes" in element { + for name in element.axes { + if not name in ptx.axes { + let mode = options.at(name + "-mode", default: "lin") + + ptx.axes.insert(name, if mode == "log" { + axis.logarithmic(name, none, none, 10) + } else { + axis.linear(name, none, none) + }) + } + } + } + } + + return ptx +} + // Setup axes dictionary // // - axis-dict (dictionary): Existing axis dictionary // - options (dictionary): Named arguments -// - plot-size (tuple): Plot width, height tuple -#let setup-axes(ctx, axis-dict, options, plot-size) = { - import "/src/axes.typ" +#let setup-axes(ptx, options) = { + import "/src/axis.typ" + let axes = ptx.axes // Get axis option for name - let get-axis-option(axis-name, name, default) = { + let get-opt(axis-name, name, default) = { let v = options.at(axis-name + "-" + name, default: default) if v == auto { default } else { v } } - for (name, axis) in axis-dict { - let used = axis.at("used", default: false) - - if not "ticks" in axis { axis.ticks = () } - axis.label = get-axis-option(name, "label", if used { $#name$ } else { axis.at("label", default: none) }) - - // Configure axis bounds - axis.min = get-axis-option(name, "min", axis.min) - axis.max = get-axis-option(name, "max", axis.max) - - if axis.min == none { - axis.min = 0 - axis.ticks.step = none - axis.ticks.minor-step = none - axis.ticks.format = none - } - if axis.max == none { axis.max = axis.min } - if axis.min == axis.max { - axis.min -= 1; axis.max += 1 - } - - axis.mode = get-axis-option(name, "mode", "lin") - axis.base = get-axis-option(name, "base", 10) - - // Configure axis orientation - axis.horizontal = get-axis-option(name, "horizontal", - get-default-axis-horizontal(name)) - - // Configure ticks - axis.ticks.list = get-axis-option(name, "ticks", ()) - axis.ticks.step = get-axis-option(name, "tick-step", axis.ticks.step) - axis.ticks.minor-step = get-axis-option(name, "minor-tick-step", axis.ticks.minor-step) - axis.ticks.decimals = get-axis-option(name, "decimals", 2) - axis.ticks.unit = get-axis-option(name, "unit", []) - axis.ticks.format = get-axis-option(name, "format", axis.ticks.format) - - // Axis break - axis.show-break = get-axis-option(name, "break", false) - axis.inset = get-axis-option(name, "inset", (0, 0)) - - // Configure grid - axis.ticks.grid = get-axis-option(name, "grid", false) - - axis-dict.at(name) = axis - } - - // Set axis options round two, after setting - // axis bounds - for (name, axis) in axis-dict { - let changed = false - - // Configure axis aspect ratio - let equal-to = get-axis-option(name, "equal", none) - if equal-to != none { - assert.eq(type(equal-to), str, - message: "Expected axis name.") - assert(equal-to != name, - message: "Axis can not be equal to itself.") - - let other = axis-dict.at(equal-to, default: none) - assert(other != none, - message: "Other axis must exist.") - assert(axis.kind == "cartesian" and other.kind == "cartesian", - message: "Bothe axes must be cartesian.") - - let dir = vector.sub(axis.target, axis.origin) - let other-dir = vector.sub(other.target, other.origin) - let ratio = vector.len(dir) / vector.len(other-dir) - - axis.min = other.min * ratio - axis.max = other.max * ratio - - changed = true - } - - if changed { - axis-dict.at(name) = axis + // Mode switching + for (name, ax) in axes { + let mode = get-opt(name, "mode", "lin") + if mode == "lin" { + ax.transform = axis._transform-lin + } else if mode == "log" { + ax.transform = axis._transform-log + ax.base = get-opt(name, "base", ax.at("base", default: 10)) + } else { + panic("Invalid axis mode: " + repr(mode)) } } - for (name, axis) in axis-dict { - axis-dict.at(name) = axes.prepare-axis(ctx, axis, name) + for (name, ax) in axes { + ax.min = get-opt(name, "min", ax.min) + ax.max = get-opt(name, "max", ax.max) + ax.label = get-opt(name, "label", ax.label) + ax.transform = get-opt(name, "transform", ax.transform) + ax.ticks.list = get-opt(name, "list", ax.ticks.list) + ax.ticks.format = get-opt(name, "format", ax.ticks.format) + ax.ticks.step = get-opt(name, "tick-step", ax.ticks.step) + ax.ticks.minor-step = get-opt(name, "minor-tick-step", ax.ticks.minor-step) + ax.grid = get-opt(name, "grid", ax.grid) + + axes.at(name) = axis.prepare(ptx, ax) } - return axis-dict + ptx.axes = axes + return ptx } /// Tests if point pt is contained in the diff --git a/src/projection.typ b/src/projection.typ index 66445d2..ee2633a 100644 --- a/src/projection.typ +++ b/src/projection.typ @@ -10,11 +10,16 @@ #let cartesian(low, high, x, y, z) = { let axes = (x, y, z) - return (..v) = { + return (..v) => { return v.pos().map(v => { - for i range(0, v.len()) { - v.at(i) = (axes.at(i).transform)(axes.at(i), v.at(i), low.at(i), high.at(i)) + for i in range(0, v.len()) { + if axes.at(i) != none { + v.at(i) = (axes.at(i).transform)(axes.at(i), v.at(i), low.at(i), high.at(i)) + } else { + v.at(i) = 0 + } } + return v }) } } diff --git a/src/spine.typ b/src/spine.typ new file mode 100644 index 0000000..e57527b --- /dev/null +++ b/src/spine.typ @@ -0,0 +1,331 @@ +#import "/src/cetz.typ" +#import cetz: vector, draw + +#import "/src/ticks.typ" +#import "/src/projection.typ" + +/// Default axis style +/// +/// #show-parameter-block("tick-limit", "int", default: 100, [Upper major tick limit.]) +/// #show-parameter-block("minor-tick-limit", "int", default: 1000, [Upper minor tick limit.]) +/// #show-parameter-block("auto-tick-factors", "array", [List of tick factors used for automatic tick step determination.]) +/// #show-parameter-block("auto-tick-count", "int", [Number of ticks to generate by default.]) +/// +/// #show-parameter-block("stroke", "stroke", [Axis stroke style.]) +/// #show-parameter-block("label.offset", "number", [Distance to move axis labels away from the axis.]) +/// #show-parameter-block("label.anchor", "anchor", [Anchor of the axis label to use for it's placement.]) +/// #show-parameter-block("label.angle", "angle", [Angle of the axis label.]) +/// #show-parameter-block("axis-layer", "float", [Layer to draw axes on (see @@on-layer() )]) +/// #show-parameter-block("grid-layer", "float", [Layer to draw the grid on (see @@on-layer() )]) +/// #show-parameter-block("background-layer", "float", [Layer to draw the background on (see @@on-layer() )]) +/// #show-parameter-block("padding", "number", [Extra distance between axes and plotting area. For schoolbook axes, this is the length of how much axes grow out of the plotting area.]) +/// #show-parameter-block("tick.stroke", "stroke", [Major tick stroke style.]) +/// #show-parameter-block("tick.minor-stroke", "stroke", [Minor tick stroke style.]) +/// #show-parameter-block("tick.offset", ("number", "ratio"), [Major tick offset along the tick's direction, can be relative to the length.]) +/// #show-parameter-block("tick.minor-offset", ("number", "ratio"), [Minor tick offset along the tick's direction, can be relative to the length.]) +/// #show-parameter-block("tick.length", ("number"), [Major tick length.]) +/// #show-parameter-block("tick.minor-length", ("number", "ratio"), [Minor tick length, can be relative to the major tick length.]) +/// #show-parameter-block("tick.label.offset", ("number"), [Major tick label offset away from the tick.]) +/// #show-parameter-block("tick.label.angle", ("angle"), [Major tick label angle.]) +/// #show-parameter-block("tick.label.anchor", ("anchor"), [Anchor of major tick labels used for positioning.]) +/// #show-parameter-block("tick.label.show", ("auto", "bool"), default: auto, [Set visibility of tick labels. A value of `auto` shows tick labels for all but mirrored axes.]) +/// #show-parameter-block("grid.stroke", "stroke", [Major grid line stroke style.]) +/// #show-parameter-block("grid.minor-stroke", "stroke", [Minor grid line stroke style.]) +/// #show-parameter-block("break-point.width", "number", [Axis break width along the axis.]) +/// #show-parameter-block("break-point.length", "number", [Axis break length.]) +/// +/// #show-parameter-block("shared-zero", ("bool", "content"), default: "$0$", [School-book style axes only: Content to display at the plots origin (0,0). If set to `false`, nothing is shown. Having this set, suppresses auto-generated ticks for $0$!]) +#let default-style = ( + mark: none, + stroke: (paint: black, cap: "square"), + fill: none, + + padding: (0cm, 0cm), + + show-zero: true, + zero-label: $0$, + + axis-layer: 0, + tick-layer: 0, + grid-layer: 0, + + tick: ( + stroke: black + 1pt, + minor-stroke: black + .5pt, + + offset: 0cm, + length: .2cm, + minor-offset: 0cm, + minor-length: .1cm, + flip: false, + + label: ( + "show": auto, + offset: .1cm, + angle: 0deg, + anchor: "center", + draw: (pos, body, angle, anchor) => { + draw.content(pos, body, angle: angle, anchor: anchor) + }, + ), + ), + + grid: ( + stroke: black + .5pt, + minor-stroke: black + .25pt, + ), + + // Overrides + x: ( + tick: ( + label: ( + anchor: "north", + ), + ), + ), + y: ( + tick: ( + flip: true, + label: ( + anchor: "east", + ), + ), + ), + x2: ( + tick: ( + flip: true, + label: ( + anchor: "south", + ), + ), + ), + y2: ( + tick: ( + label: ( + anchor: "west", + ), + ), + ), +) + +/// Default schoolbook style +#let default-style-schoolbook = cetz.util.merge-dictionary(default-style, ( + mark: (end: "straight"), + padding: (.1cm, 1em), + + tick: ( + offset: -.1cm, + length: .2cm, + minor-offset: -.05cm, + minor-length: .1cm, + ), +)) + +#let _prepare-style(ptx, style) = { + let ctx = ptx.cetz-ctx + let resolve-number = cetz.util.resolve-number.with(ctx) + let relative-to(val, to) = { + return if type(val) == ratio { + val * to + } else { + val + } + } + let resolve-relative-number(val, to) = { + return resolve-number(relative-to(val, to)) + } + + if type(style.padding) != array { + style.padding = (style.padding,) * 2 + } + style.padding = style.padding.map(resolve-number) + + style.tick.offset = resolve-number(style.tick.offset) + style.tick.length = resolve-number(style.tick.length) + style.tick.minor-offset = resolve-relative-number(style.tick.minor-offset, style.tick.offset) + style.tick.minor-length = resolve-relative-number(style.tick.minor-length, style.tick.length) + + style.tick.label.offset = resolve-number(style.tick.label.offset) + + return style +} + +#let _get-axis-style(ptx, style, name) = { + return _prepare-style(ptx, if name in style { + cetz.util.merge-dictionary(style, style.at(name)) + } else { + style + }) +} + +#let _add-cartesian-projection(ptx, origin, size, x, y, z) = { + let size = if size == auto { + ptx.default-size + } else { + size + } + + let (_, origin) = cetz.coordinate.resolve(ptx.cetz-ctx, origin) + let size = size.map(cetz.util.resolve-number.with(ptx.cetz-ctx)) + + let axes = (x, y, z) + .map(name => if name != none { ptx.axes.at(str(name), default: none) }) + + let subplot = ( + axes: (x, y, z), + proj: projection.cartesian(origin, vector.add(origin, size), ..axes) + ) + ptx.plots.push(subplot) + + return ptx +} + + +/// +#let cartesian-scientific(x, y, origin: (0, 0), size: auto, ..style) = { + let style = style.named() + + (( + priority: 100, + fn: ptx => { + ptx = _add-cartesian-projection(ptx, origin, size, x, y, none) + + let spine = ( + name: none, + axes: (x, y), + draw: (ptx, proj) => { + let axes = (x, y) + .map(name => ptx.axes.at(str(name), default: none)) + let x = axes.at(0) + let y = axes.at(1) + + let style = _prepare-style(ptx, cetz.styles.resolve(ptx.cetz-ctx.style, + root: "axes", merge: style, base: default-style)) + let x-style = _get-axis-style(ptx, style, "x") + let y-style = _get-axis-style(ptx, style, "y") + let x2-style = _get-axis-style(ptx, style, "x2") + let y2-style = _get-axis-style(ptx, style, "y2") + + let (south-west, south-east, north-west, north-east) = proj( + (x.min, y.min), (x.max, y.min), + (x.min, y.max), (x.max, y.max), + ) + + let x-padding = x-style.padding + let y-padding = y-style.padding + + let x-low = vector.add(south-west, (-x-padding.at(0), -y-padding.at(0))) + let x-high = vector.add(south-east, (+x-padding.at(1), -y-padding.at(0))) + let y-low = vector.add(south-west, (-x-padding.at(0), -y-padding.at(0))) + let y-high = vector.add(north-west, (-x-padding.at(0), y-padding.at(1))) + + let x2-low = vector.add(north-west, (-x-padding.at(0), y-padding.at(1))) + let x2-high = vector.add(north-east, (+x-padding.at(1), y-padding.at(1))) + let y2-low = vector.add(south-east, (x-padding.at(1), -y-padding.at(0))) + let y2-high = vector.add(north-east, (x-padding.at(1), y-padding.at(1))) + + let axes = ( + (x, 0, south-west, south-east, x-low, x-high, x-style, false), + (y, 1, south-west, north-west, y-low, y-high, y-style, false), + (x, 0, north-west, north-east, x2-low, x2-high, x2-style, true), + (y, 1, south-east, north-east, y2-low, y2-high, y2-style, true), + ) + + for (ax, component, low, high, frame-low, frame-high, style, mirror) in axes { + draw.on-layer(style.axis-layer, { + draw.line(frame-low, frame-high, stroke: style.stroke, mark: style.mark) + }) + if "computed-ticks" in ax { + let low = low.enumerate().map(((i, v)) => { + if i == component { v } else { frame-low.at(i) } + }) + let high = high.enumerate().map(((i, v)) => { + if i == component { v } else { frame-low.at(i) } + }) + + if not mirror { + ticks.draw-cartesian-grid(low, high, component, ax, ax.computed-ticks, (0,0), (1,0), style) + } + ticks.draw-cartesian(low, high, ax.computed-ticks, style, is-mirror: mirror) + } + } + }, + ) + ptx.spines.push(spine) + return ptx + } + ),) +} + +/// +#let cartesian-schoolbook(x, y, zero: (0, 0, 0), origin: (0, 0, 0), size: auto, ..style) = { + (( + priority: 100, + fn: ptx => { + ptx = _add-cartesian-projection(ptx, origin, size, x, y, none) + + let spine = ( + name: none, + axes: (x, y), + draw: (ptx, proj) => { + let (x, y) = (x, y) + .map(name => ptx.axes.at(str(name), default: none)) + + let style = _prepare-style(ptx, cetz.styles.resolve(ptx.cetz-ctx.style, + root: "axes", merge: style.named(), base: default-style-schoolbook)) + let x-style = _get-axis-style(ptx, style, "x") + let y-style = _get-axis-style(ptx, style, "y") + + let (zero, min-x, max-x, min-y, max-y) = proj( + zero, + vector.add(zero, (x.min, 0, 0)), vector.add(zero, (x.max, 0, 0)), + vector.add(zero, (0, y.min, 0)), vector.add(zero, (0, y.max, 0)), + ) + + let x-padding = x-style.padding + if type(x-padding) != array { + x-padding = (x-padding, x-padding) + } + + let y-padding = y-style.padding + if type(y-padding) != array { + y-padding = (y-padding, y-padding) + } + + let outset-lo-x = (x-padding.at(0), 0) + let outset-hi-x = (x-padding.at(1), 0) + let outset-lo-y = (0., y-padding.at(0)) + let outset-hi-y = (0., y-padding.at(1)) + let outset-min-x = vector.scale(outset-lo-x, -1) + let outset-max-x = vector.scale(outset-hi-x, +1) + let outset-min-y = vector.scale(outset-lo-y, -1) + let outset-max-y = vector.scale(outset-hi-y, +1) + + draw.on-layer(x-style.axis-layer, { + draw.line((rel: outset-min-x, to: min-x), + (rel: outset-max-x, to: max-x), + mark: x-style.mark, + stroke: x-style.stroke) + }) + if "computed-ticks" in x { + ticks.draw-cartesian-grid(min-x, max-x, 0, x, x.computed-ticks, min-y, max-y, x-style) + ticks.draw-cartesian(min-x, max-x, x.computed-ticks, x-style) + } + + draw.on-layer(y-style.axis-layer, { + draw.line((rel: outset-min-y, to: min-y), + (rel: outset-max-y, to: max-y), + mark: y-style.mark, + stroke: y-style.stroke) + }) + if "computed-ticks" in y { + ticks.draw-cartesian-grid(min-y, max-y, 1, y, y.computed-ticks, min-x, max-x, y-style) + ticks.draw-cartesian(min-y, max-y, y.computed-ticks, y-style) + } + } + ) + ptx.spines.push(spine) + return ptx + }, + ),) +} diff --git a/src/ticks.typ b/src/ticks.typ index d53156e..8418b0d 100644 --- a/src/ticks.typ +++ b/src/ticks.typ @@ -1,13 +1,44 @@ +#import "/src/cetz.typ" +#import cetz: util, vector, draw + +#import "/src/plot/formats.typ" + +// Format a tick value +#let format-tick-value(value, tic-options) = { + // Without it we get negative zero in conversion + // to content! Typst has negative zero floats. + if value == 0 { value = 0 } + + if type(value) != std.content { + let format = tic-options.at("format", default: "float") + if format == none { + value = [] + } else if type(format) == std.content { + value = format + } else if type(format) == function { + value = (format)(value) + } else if format == "sci" { + value = formats.sci(value, digits: tic-options.at("decimals", default: 2)) + } else { + value = formats.decimal(value, digits: tic-options.at("decimals", default: 2)) + } + } else if type(value) != std.content { + value = str(value) + } + + return value +} + // Compute list of linear ticks for axis // // - axis (axis): Axis -#let compute-linear-ticks(axis, style, add-zero: true) = { +#let compute-linear-ticks(axis, add-zero: true) = { let (min, max) = (axis.min, axis.max) let dt = max - min; if (dt == 0) { dt = 1 } let ticks = axis.ticks let ferr = util.float-epsilon - let tick-limit = style.tick-limit - let minor-tick-limit = style.minor-tick-limit + let tick-limit = axis.at("tick-limit", default: 100) + let minor-tick-limit = axis.at("minor-tick-limit", default: 1000) let l = () if ticks != none { @@ -68,7 +99,7 @@ // Compute list of linear ticks for axis // // - axis (axis): Axis -#let compute-logarithmic-ticks(axis, style, add-zero: true) = { +#let compute-logarithmic-ticks(axis, add-zero: true) = { let ferr = util.float-epsilon let (min, max) = ( calc.log(calc.max(axis.min, ferr), base: axis.base), @@ -77,8 +108,8 @@ let dt = max - min; if (dt == 0) { dt = 1 } let ticks = axis.ticks - let tick-limit = style.tick-limit - let minor-tick-limit = style.minor-tick-limit + let tick-limit = axis.at("tick-limit", default: 100) + let minor-tick-limit = axis.at("minor-tick-limit", default: 1000) let l = () if ticks != none { @@ -140,25 +171,29 @@ // Get list of fixed axis ticks // // - axis (axis): Axis object -#let fixed-ticks(axis) = { - let l = () - if "list" in axis.ticks { - for t in axis.ticks.list { - let (v, label) = (none, none) - if type(t) in (float, int) { - v = t - label = format-tick-value(t, axis.ticks) - } else { - (v, label) = t - } +#let fixed-ticks(ax) = { + let list = ax.at("list", default: none) + if type(list) == function { + list = (list)(ax) + } + if type(list) != array { + return () + } - v = value-on-axis(axis, v) - if v != none and v >= 0 and v <= 1 { - l.push((v, label, true)) - } + return list.map(t => { + let (v, label) = (none, none) + if type(t) in (float, int) { + v = t + label = format-tick-value(t, axis.ticks) + } else { + (v, label) = t } - } - return l + + v = value-on-axis(axis, v) + if v != none and v >= 0 and v <= 1 { + l.push((v, label, true)) + } + }) } // Compute list of axis ticks @@ -166,15 +201,19 @@ // A tick triple has the format: // (rel-value: float, label: content, major: bool) // +// - mode (str): "lin" or "log" // - axis (axis): Axis object -#let compute-ticks(axis, style, add-zero: true) = { +#let compute-ticks(mode, axis, add-zero: true) = { + let auto-tick-count = 11 + let auto-tick-factors = (1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10) + let find-max-n-ticks(axis, n: 11) = { let dt = calc.abs(axis.max - axis.min) let scale = calc.floor(calc.log(dt, base: 10) - 1) if scale > 5 or scale < -5 {return none} let (step, best) = (none, 0) - for s in style.auto-tick-factors { + for s in auto-tick-factors { s = s * calc.pow(10, scale) let divs = calc.abs(dt / s) @@ -188,7 +227,7 @@ if axis == none or axis.ticks == none { return () } if axis.ticks.step == auto { - axis.ticks.step = find-max-n-ticks(axis, n: style.auto-tick-count) + axis.ticks.step = find-max-n-ticks(axis, n: auto-tick-count) } if axis.ticks.minor-step == auto { axis.ticks.minor-step = if axis.ticks.step != none { @@ -198,11 +237,104 @@ } } - let ticks = if axis.mode == "log" { - compute-logarithmic-ticks(axis, style, add-zero: add-zero) + let ticks = if mode == "log" { + compute-logarithmic-ticks(axis, add-zero: add-zero) } else { - compute-linear-ticks(axis, style, add-zero: add-zero) + compute-linear-ticks(axis, add-zero: add-zero) } ticks += fixed-ticks(axis) return ticks } + +// Place a list of tick marks and labels along a line +#let draw-cartesian(start, stop, ticks, style, is-mirror: false, show-zero: true) = { + let draw-label = style.tick.label.draw + + draw.on-layer(style.tick-layer, { + let dir = vector.norm(vector.sub(stop, start)) + let norm = (-dir.at(1), dir.at(0), dir.at(2, default: 0)) + + let def(v, d) = { + return if v == none or v == auto {d} else {v} + } + + let show-label = style.tick.label.show + if show-label == auto { + show-label = not is-mirror + } + + for (distance, label, is-major) in ticks { + let offset = if is-major { style.tick.offset } else { style.tick.minor-offset } + let length = if is-major { style.tick.length } else { style.tick.minor-length } + let stroke = if is-major { style.tick.stroke } else { style.tick.minor-stroke } + + let pt = vector.lerp(start, stop, distance) + if style.tick.flip { + offset = -offset - length + } + + let a = vector.add(pt, vector.scale(norm, offset)) + let b = vector.add(a, vector.scale(norm, length)) + + draw.line(a, b, stroke: stroke) + + if draw-label != none and show-label and label != none { + let offset = style.tick.label.offset + if style.tick.flip { + offset = -offset - length + } + + let angle = def(style.tick.label.angle, 0deg) + let anchor = def(style.tick.label.anchor, "center") + let pos = vector.sub(a, vector.scale(norm, offset)) + + draw-label(pos, label, angle, anchor) + } + } + }) +} + +// Draw grid lines for the ticks of an axis +// +// - ptx (context): Plot context +// - start (vector): Axis start +// - stop (vector): Axis stop +// - component (int): Vector compontent to use as direction +// - axis (dictionary): The axis +// - ticks (array): The computed ticks +// - low (vector): Start position of a grid-line at tick 0 +// - high (vector): End position of a grid-line at tick 0 +// - style (style): Style +#let draw-cartesian-grid(start, stop, component, axis, ticks, low, high, style) = { + let kind = if axis.grid in (true, "major") { + 1 + } else if axis.grid == "minor" { + 2 + } else if axis.grid == "both" { + 3 + } else { + 0 + } + + if kind > 0 { + draw.on-layer(style.grid-layer, { + for (distance, label, is-major) in ticks { + let offset = vector.lerp(start, stop, distance) + + let start = low + start.at(component) = offset.at(component) + let end = high + end.at(component) = offset.at(component) + + // Draw a minor line + if not is-major and kind >= 2 { + draw.line(start, end, stroke: style.grid.minor-stroke) + } + // Draw a major line + if is-major and (kind == 1 or kind == 3) { + draw.line(start, end, stroke: style.grid.stroke) + } + } + }) + } +}