diff --git a/src/axes.typ b/src/axes.typ deleted file mode 100644 index 19cefdb..0000000 --- a/src/axes.typ +++ /dev/null @@ -1,920 +0,0 @@ -#import "/src/cetz.typ": util, draw, vector, matrix, styles, process, drawable, path-util, process -#import "/src/plot/formats.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("overshoot", "number", [School-book style axes only: Extra length to add to the end (right, top) of axes.]) -/// #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("break-point.width", "number", [Axis break width along the axis.]) -/// #show-parameter-block("break-point.length", "number", [Axis break length.]) -/// #show-parameter-block("minor-grid.stroke", "stroke", [Minor grid line stroke style.]) -/// #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 = ( - tick-limit: 100, - minor-tick-limit: 1000, - auto-tick-factors: (1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10), // Tick factor to try - auto-tick-count: 11, // Number of ticks the plot tries to place - fill: none, - stroke: auto, - label: ( - offset: .2cm, // Axis label offset - anchor: auto, // Axis label anchor - angle: auto, // Axis label angle - ), - axis-layer: 0, - grid-layer: 0, - background-layer: 0, - padding: 0, - tick: ( - fill: none, - stroke: black + 1pt, - minor-stroke: black + .5pt, - offset: 0, - minor-offset: 0, - length: .1cm, // Tick length: Number - minor-length: 70%, // Minor tick length: Number, Ratio - label: ( - offset: .15cm, // Tick label offset - angle: 0deg, // Tick label angle - anchor: auto, // Tick label anchor - "show": auto, // Show tick labels for axes in use - ) - ), - break-point: ( - width: .75cm, - length: .15cm, - ), - grid: ( - stroke: (paint: gray.lighten(50%), thickness: 1pt), - ), - minor-grid: ( - stroke: (paint: gray.lighten(50%), thickness: .5pt), - ), -) - -// Default Scientific Style -#let default-style-scientific = util.merge-dictionary(default-style, ( - left: (tick: (label: (anchor: "east"))), - bottom: (tick: (label: (anchor: "north"))), - right: (tick: (label: (anchor: "west"))), - top: (tick: (label: (anchor: "south"))), - stroke: (cap: "square"), - padding: 0, -)) - -// Default Schoolbook Style -#let default-style-schoolbook = util.merge-dictionary(default-style, ( - x: (stroke: auto, fill: none, mark: (start: none, end: "straight"), - tick: (label: (anchor: "north"))), - y: (stroke: auto, fill: none, mark: (start: none, end: "straight"), - tick: (label: (anchor: "east"))), - label: (offset: .1cm), - origin: (label: (offset: .05cm)), - padding: .1cm, // Axis padding on both sides outsides the plotting area - overshoot: .5cm, // Axis end "overshoot" out of the plotting area - tick: ( - offset: -50%, - minor-offset: -50%, - length: .2cm, - minor-length: 70%, - ), - shared-zero: $0$, // Show zero tick label at (0, 0) -)) - -#let _prepare-style(ctx, style) = { - if type(style) != dictionary { return style } - - let res = util.resolve-number.with(ctx) - let rel-to(v, to) = { - if type(v) == ratio { - return v * to / 100% - } else { - return res(v) - } - } - - style.tick.length = res(style.tick.length) - style.tick.offset = rel-to(style.tick.offset, style.tick.length) - style.tick.minor-length = rel-to(style.tick.minor-length, style.tick.length) - style.tick.minor-offset = rel-to(style.tick.minor-offset, style.tick.minor-length) - style.tick.label.offset = res(style.tick.label.offset) - - // Break points - style.break-point.width = res(style.break-point.width) - style.break-point.length = res(style.break-point.length) - - // Padding - style.padding = res(style.padding) - - if "overshoot" in style { - style.overshoot = res(style.overshoot) - } - - return style -} - -#let _get-axis-style(ctx, style, name) = { - if not name in style { - return style - } - - style = styles.resolve(style, merge: style.at(name)) - return _prepare-style(ctx, style) -} - -#let _get-grid-type(axis) = { - let grid = axis.ticks.at("grid", default: false) - if grid == "major" or grid == true { return 1 } - if grid == "minor" { return 2 } - if grid == "both" { return 3 } - return 0 -} - -#let _inset-axis-points(ctx, style, axis, start, end) = { - if axis == none { return (start, end) } - - let (low, high) = axis.inset.map(v => util.resolve-number(ctx, v)) - - let is-horizontal = start.at(1) == end.at(1) - if is-horizontal { - start = vector.add(start, (low, 0)) - end = vector.sub(end, (high, 0)) - } else { - start = vector.add(start, (0, low)) - end = vector.sub(end, (0, high)) - } - return (start, end) -} - -#let _draw-axis-line(start, end, axis, is-horizontal, style) = { - let enabled = if axis != none and axis.show-break { - axis.min > 0 or axis.max < 0 - } else { false } - - if enabled { - let size = if is-horizontal { - (style.break-point.width, 0) - } else { - (0, style.break-point.width, 0) - } - - let up = if is-horizontal { - (0, style.break-point.length) - } else { - (style.break-point.length, 0) - } - - let add-break(is-end) = { - let a = () - let b = (rel: vector.scale(size, .3), update: false) - let c = (rel: vector.add(vector.scale(size, .4), vector.scale(up, -1)), update: false) - let d = (rel: vector.add(vector.scale(size, .6), vector.scale(up, +1)), update: false) - let e = (rel: vector.scale(size, .7), update: false) - let f = (rel: size) - - let mark = if is-end { - style.at("mark", default: none) - } - draw.line(a, b, c, d, e, f, stroke: style.stroke, mark: mark) - } - - draw.merge-path({ - draw.move-to(start) - if axis.min > 0 { - add-break(false) - draw.line((rel: size, to: start), end, mark: style.at("mark", default: none)) - } else if axis.max < 0 { - draw.line(start, (rel: vector.scale(size, -1), to: end)) - add-break(true) - } - }, stroke: style.stroke) - } else { - draw.line(start, end, stroke: style.stroke, mark: style.at("mark", default: none)) - } -} - -// Construct Axis Object -// -// - min (number): Minimum value -// - max (number): Maximum value -// - ticks (dictionary): Tick settings: -// - step (number): Major tic step -// - minor-step (number): Minor tic step -// - decimals (int): Tick float decimal length -// - label (content): Axis label -// - mode (string): Axis scaling function. Takes `lin` or `log` -// - base (number): Base for tick labels when logarithmically scaled. -#let axis(min: -1, max: 1, label: none, - ticks: (step: auto, minor-step: none, - decimals: 2, grid: false, - format: "float" - ), - mode: auto, base: auto) = ( - min: min, max: max, ticks: ticks, label: label, inset: (0, 0), show-break: false, mode: mode, base: base, - kind: "cartesian", -) - -// 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 -} - -// Get value on axis [0, 1] -// -// - axis (axis): Axis -// - v (number): Value -// -> float -#let value-on-axis(axis, v) = { - if v == none { return } - let (min, max) = (axis.min, axis.max) - let dt = max - min; if dt == 0 { dt = 1 } - - return (v - min) / dt -} - -// Compute list of linear ticks for axis -// -// - axis (axis): Axis -#let compute-linear-ticks(axis, style, 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 l = () - if ticks != none { - let major-tick-values = () - if "step" in ticks and ticks.step != none { - assert(ticks.step >= 0, - message: "Axis tick step must be positive and non 0.") - if axis.min > axis.max { ticks.step *= -1 } - - let s = 1 / ticks.step - - let num-ticks = int(max * s + 1.5) - int(min * s) - assert(num-ticks <= tick-limit, - message: "Number of major ticks exceeds limit " + str(tick-limit)) - - let n = range(int(min * s), int(max * s + 1.5)) - for t in n { - let v = (t / s - min) / dt - if t / s == 0 and not add-zero { continue } - - if v >= 0 - ferr and v <= 1 + ferr { - l.push((v, format-tick-value(t / s, ticks), true)) - major-tick-values.push(v) - } - } - } - - if "minor-step" in ticks and ticks.minor-step != none { - assert(ticks.minor-step >= 0, - message: "Axis minor tick step must be positive") - if axis.min > axis.max { ticks.minor-step *= -1 } - - let s = 1 / ticks.minor-step - - let num-ticks = int(max * s + 1.5) - int(min * s) - assert(num-ticks <= minor-tick-limit, - message: "Number of minor ticks exceeds limit " + str(minor-tick-limit)) - - let n = range(int(min * s), int(max * s + 1.5)) - for t in n { - let v = (t / s - min) / dt - if v in major-tick-values { - // Prefer major ticks over minor ticks - continue - } - - if v != none and v >= 0 and v <= 1 + ferr { - l.push((v, none, false)) - } - } - } - - } - - return l -} - -// Compute list of linear ticks for axis -// -// - axis (axis): Axis -#let compute-logarithmic-ticks(axis, style, add-zero: true) = { - let ferr = util.float-epsilon - let (min, max) = ( - calc.log(calc.max(axis.min, ferr), base: axis.base), - calc.log(calc.max(axis.max, ferr), base: axis.base) - ) - 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 l = () - - if ticks != none { - let major-tick-values = () - if "step" in ticks and ticks.step != none { - assert(ticks.step >= 0, - message: "Axis tick step must be positive and non 0.") - if axis.min > axis.max { ticks.step *= -1 } - - let s = 1 / ticks.step - - let num-ticks = int(max * s + 1.5) - int(min * s) - assert(num-ticks <= tick-limit, - message: "Number of major ticks exceeds limit " + str(tick-limit)) - - let n = range( - int(min * s), - int(max * s + 1.5) - ) - - for t in n { - let v = (t / s - min) / dt - if t / s == 0 and not add-zero { continue } - - if v >= 0 - ferr and v <= 1 + ferr { - l.push((v, format-tick-value( calc.pow(axis.base, t / s), ticks), true)) - major-tick-values.push(v) - } - } - } - - if "minor-step" in ticks and ticks.minor-step != none { - assert(ticks.minor-step >= 0, - message: "Axis minor tick step must be positive") - if axis.min > axis.max { ticks.minor-step *= -1 } - - let s = 1 / ticks.step - let n = range(int(min * s)-1, int(max * s + 1.5)+1) - - for t in n { - for vv in range(1, int(axis.base / ticks.minor-step)) { - - let v = ( (calc.log(vv * ticks.minor-step, base: axis.base) + t)/ s - min) / dt - if v in major-tick-values {continue} - - if v != none and v >= 0 and v <= 1 + ferr { - l.push((v, none, false)) - } - - } - - } - } - } - - return l -} - -// 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 - } - - v = value-on-axis(axis, v) - if v != none and v >= 0 and v <= 1 { - l.push((v, label, true)) - } - } - } - return l -} - -// Compute list of axis ticks -// -// A tick triple has the format: -// (rel-value: float, label: content, major: bool) -// -// - axis (axis): Axis object -#let compute-ticks(axis, style, add-zero: true) = { - 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 { - s = s * calc.pow(10, scale) - - let divs = calc.abs(dt / s) - if divs >= best and divs <= n { - step = s - best = divs - } - } - return step - } - - 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) - } - if axis.ticks.minor-step == auto { - axis.ticks.minor-step = if axis.ticks.step != none { - axis.ticks.step / 5 - } else { - none - } - } - - let ticks = if axis.mode == "log" { - compute-logarithmic-ticks(axis, style, add-zero: add-zero) - } else { - compute-linear-ticks(axis, style, add-zero: add-zero) - } - ticks += fixed-ticks(axis) - return ticks -} - -// Prepares the axis post creation. The given axis -// must be completely set-up, including its interval. -// Returns the prepared axis -#let prepare-axis(ctx, axis, name) = { - let style = styles.resolve(ctx.style, root: "axes", - base: default-style-scientific) - style = _prepare-style(ctx, style) - style = _get-axis-style(ctx, style, name) - - if type(axis.inset) != array { - axis.inset = (axis.inset, axis.inset) - } - - axis.inset = axis.inset.map(v => util.resolve-number(ctx, v)) - - if axis.show-break { - if axis.min > 0 { - axis.inset.at(0) += style.break-point.width - } else if axis.max < 0 { - axis.inset.at(1) += style.break-point.width - } - } - - return axis -} - -// Transform a single value along a cartesian axis -#let transform-cartesian(axis, v) = { - let a = axis.origin - let b = axis.target - - let length = vector.dist(a, b) - axis.inset.sum() - let offset = axis.inset.at(0) - - let transform-func(n) = if axis.mode == "log" { - calc.log(calc.max(n, util.float-epsilon), base: axis.base) - } else { - n - } - - let factor = length / (transform-func(axis.max) - transform-func(axis.min)) - return vector.scale( - vector.norm(vector.sub(b, a)), - (transform-func(v) - transform-func(axis.min)) * factor + offset) -} - -// Transform a single vector along a x, y and z axis -// -// - axes (list): List of axes -// - vec (vector): Input vector to transform -// -> vector -#let transform-vec(axes, vec) = { - let res = (0, 0, 0) - for (dim, axis) in axes.enumerate() { - let v = vec.at(dim, default: 0) - - if axis.kind == "cartesian" { - res = vector.add(res, transform-cartesian(axis, v)) - } else { - panic("Unknown axit type " + repr(axis.kind)) - } - } - return res -} - -// Draw inside viewport coordinates of two axes -// -// - axes (list): List of axes -// - name (string,none): Group name -#let axis-viewport(axes, body, name: none) = { - draw.group(name: name, (ctx => { - let transform = ctx.transform - - ctx.transform = matrix.ident() - let (ctx, drawables, bounds) = process.many(ctx, util.resolve-body(ctx, body)) - - ctx.transform = transform - - drawables = drawables.map(d => { - if "segments" in d { - d.segments = d.segments.map(((kind, ..pts)) => { - (kind, ..pts.map(pt => { - transform-vec(axes, pt) - })) - }) - } - if "pos" in d { - d.pos = transform-vec(axes, d.pos) - } - return d - }) - - return ( - ctx: ctx, - drawables: drawable.apply-transform(ctx.transform, drawables) - ) - },)) -} - -// Draw grid lines for the ticks of an axis -// -// - cxt (context): -// - 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 -// - dir (vector): Normalized grid direction vector along the grid axis -// - style (style): Axis style -#let draw-grid-lines(ctx, axis, ticks, low, high, dir, style) = { - let offset = (0,0) - if axis.inset != none { - let (inset-low, inset-high) = axis.inset.map(v => util.resolve-number(ctx, v)) - offset = vector.scale(vector.norm(dir), inset-low) - dir = vector.sub(dir, vector.scale(vector.norm(dir), inset-low + inset-high)) - } - - let kind = _get-grid-type(axis) - if kind > 0 { - for (distance, label, is-major) in ticks { - let offset = vector.add(vector.scale(dir, distance), offset) - let start = vector.add(low, offset) - let end = vector.add(high, offset) - - // Draw a major line - if is-major and (kind == 1 or kind == 3) { - draw.line(start, end, stroke: style.grid.stroke) - } - // Draw a minor line - if not is-major and kind >= 2 { - draw.line(start, end, stroke: style.minor-grid.stroke) - } - } - } -} - -// Place a list of tick marks and labels along a path -#let place-ticks-on-line(ticks, start, stop, style, flip: false, is-mirror: false) = { - let dir = vector.sub(stop, start) - let norm = vector.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 = style.tick.offset - let length = if is-major { style.tick.length } else { style.tick.minor-length } - if flip { - offset *= -1 - length *= -1 - } - - let pt = vector.lerp(start, stop, distance) - let a = vector.add(pt, vector.scale(norm, offset)) - let b = vector.add(a, vector.scale(norm, length)) - - draw.line(a, b, stroke: style.tick.stroke) - - if show-label and label != none { - let offset = style.tick.label.offset - if flip { - offset *= -1 - length *= -1 - } - - let c = vector.sub(if length <= 0 { b } else { a }, - vector.scale(norm, offset)) - - let angle = def(style.tick.label.angle, 0deg) - let anchor = def(style.tick.label.anchor, "center") - - draw.content(c, [#label], angle: angle, anchor: anchor) - } - } -} - -// Draw up to four axes in an "scientific" style at origin (0, 0) -// -// - size (array): Size (width, height) -// - left (axis): Left (y) axis -// - bottom (axis): Bottom (x) axis -// - right (axis): Right axis -// - top (axis): Top axis -// - name (string): Object name -// - draw-unset (bool): Draw axes that are set to `none` -// - ..style (any): Style -#let scientific(size: (1, 1), - left: none, - right: auto, - bottom: none, - top: auto, - draw-unset: true, - name: none, - ..style) = { - import draw: * - - if right == auto { - if left != none { - right = left; right.is-mirror = true - } else { - right = none - } - } - if top == auto { - if bottom != none { - top = bottom; top.is-mirror = true - } else { - top = none - } - } - - group(name: name, ctx => { - let (w, h) = size - anchor("origin", (0, 0)) - - let style = style.named() - style = styles.resolve(ctx.style, merge: style, root: "axes", - base: default-style-scientific) - style = _prepare-style(ctx, style) - - // Compute ticks - let x-ticks = compute-ticks(bottom, style) - let y-ticks = compute-ticks(left, style) - let x2-ticks = compute-ticks(top, style) - let y2-ticks = compute-ticks(right, style) - - // Draw frame - if style.fill != none { - on-layer(style.background-layer, { - rect((0,0), (w,h), fill: style.fill, stroke: none) - }) - } - - // Draw grid - group(name: "grid", ctx => { - let axes = ( - ("bottom", (0,0), (0,h), (+w,0), x-ticks, bottom), - ("top", (0,h), (0,0), (+w,0), x2-ticks, top), - ("left", (0,0), (w,0), (0,+h), y-ticks, left), - ("right", (w,0), (0,0), (0,+h), y2-ticks, right), - ) - for (name, start, end, direction, ticks, axis) in axes { - if axis == none { continue } - - let style = _get-axis-style(ctx, style, name) - let is-mirror = axis.at("is-mirror", default: false) - - if not is-mirror { - on-layer(style.grid-layer, { - draw-grid-lines(ctx, axis, ticks, start, end, direction, style) - }) - } - } - }) - - // Draw axes - group(name: "axes", { - let axes = ( - ("bottom", (0, 0), (w, 0), (0, -1), false, x-ticks, bottom,), - ("top", (0, h), (w, h), (0, +1), true, x2-ticks, top,), - ("left", (0, 0), (0, h), (-1, 0), true, y-ticks, left,), - ("right", (w, 0), (w, h), (+1, 0), false, y2-ticks, right,) - ) - let label-placement = ( - bottom: ("south", "north", 0deg), - top: ("north", "south", 0deg), - left: ("west", "south", 90deg), - right: ("east", "north", 90deg), - ) - - for (name, start, end, outsides, flip, ticks, axis) in axes { - let style = _get-axis-style(ctx, style, name) - let is-mirror = axis == none or axis.at("is-mirror", default: false) - let is-horizontal = name in ("bottom", "top") - - if style.padding != 0 { - let padding = vector.scale(outsides, style.padding) - start = vector.add(start, padding) - end = vector.add(end, padding) - } - - let (data-start, data-end) = _inset-axis-points(ctx, style, axis, start, end) - - let path = _draw-axis-line(start, end, axis, is-horizontal, style) - on-layer(style.axis-layer, { - group(name: "axis", { - if draw-unset or axis != none { - path; - place-ticks-on-line(ticks, data-start, data-end, style, flip: flip, is-mirror: is-mirror) - } - }) - - if axis != none and axis.label != none and not is-mirror { - let offset = vector.scale(outsides, style.label.offset) - let (group-anchor, content-anchor, angle) = label-placement.at(name) - - if style.label.anchor != auto { - content-anchor = style.label.anchor - } - if style.label.angle != auto { - angle = style.label.angle - } - - content((rel: offset, to: "axis." + group-anchor), - [#axis.label], - angle: angle, - anchor: content-anchor) - } - }) - } - }) - }) -} - -// Draw two axes in a "school book" style -// -// - x-axis (axis): X axis -// - y-axis (axis): Y axis -// - size (array): Size (width, height) -// - x-position (number): X Axis position -// - y-position (number): Y Axis position -// - name (string): Object name -// - ..style (any): Style -#let school-book(x-axis, y-axis, - size: (1, 1), - x-position: 0, - y-position: 0, - name: none, - ..style) = { - import draw: * - - group(name: name, ctx => { - let (w, h) = size - anchor("origin", (0, 0)) - - let style = style.named() - style = styles.resolve( - ctx.style, - merge: style, - root: "axes", - base: default-style-schoolbook) - style = _prepare-style(ctx, style) - - let x-position = calc.min(calc.max(y-axis.min, x-position), y-axis.max) - let y-position = calc.min(calc.max(x-axis.min, y-position), x-axis.max) - let x-y = value-on-axis(y-axis, x-position) * h - let y-x = value-on-axis(x-axis, y-position) * w - - let shared-zero = style.shared-zero != false and x-position == 0 and y-position == 0 - - let x-ticks = compute-ticks(x-axis, style, add-zero: not shared-zero) - let y-ticks = compute-ticks(y-axis, style, add-zero: not shared-zero) - - // Draw grid - group(name: "grid", ctx => { - let axes = ( - ("x", (0,0), (0,h), (+w,0), x-ticks, x-axis), - ("y", (0,0), (w,0), (0,+h), y-ticks, y-axis), - ) - - for (name, start, end, direction, ticks, axis) in axes { - if axis == none { continue } - - let style = _get-axis-style(ctx, style, name) - on-layer(style.grid-layer, { - draw-grid-lines(ctx, axis, ticks, start, end, direction, style) - }) - } - }) - - // Draw axes - group(name: "axes", { - let axes = ( - ("x", (0, x-y), (w, x-y), (1, 0), false, x-ticks, x-axis), - ("y", (y-x, 0), (y-x, h), (0, 1), true, y-ticks, y-axis), - ) - let label-pos = ( - x: ("north", (0,-1)), - y: ("east", (-1,0)), - ) - - on-layer(style.axis-layer, { - for (name, start, end, dir, flip, ticks, axis) in axes { - let style = _get-axis-style(ctx, style, name) - - let pad = style.padding - let overshoot = style.overshoot - let vstart = vector.sub(start, vector.scale(dir, pad)) - let vend = vector.add(end, vector.scale(dir, pad + overshoot)) - let is-horizontal = name == "x" - - let (data-start, data-end) = _inset-axis-points(ctx, style, axis, start, end) - group(name: "axis", { - _draw-axis-line(vstart, vend, axis, is-horizontal, style) - place-ticks-on-line(ticks, data-start, data-end, style, flip: flip) - }) - - if axis.label != none { - let (content-anchor, offset-dir) = label-pos.at(name) - - let angle = if style.label.angle not in (none, auto) { - style.label.angle - } else { 0deg } - if style.label.anchor not in (none, auto) { - content-anchor = style.label.anchor - } - - let offset = vector.scale(offset-dir, style.label.offset) - content((rel: offset, to: vend), - [#axis.label], - angle: angle, - anchor: content-anchor) - } - } - - if shared-zero { - let pt = (rel: (-style.tick.label.offset, -style.tick.label.offset), - to: (y-x, x-y)) - let zero = if type(style.shared-zero) == std.content { - style.shared-zero - } else { - $0$ - } - content(pt, zero, anchor: "north-east") - } - }) - }) - }) -} diff --git a/src/axis.typ b/src/axis.typ new file mode 100644 index 0000000..65b8323 --- /dev/null +++ b/src/axis.typ @@ -0,0 +1,74 @@ +#import "/src/ticks.typ" +#import "/src/plot/util.typ" + +// Grid modes +#let _get-grid-mode(mode) = { + return if mode == true or mode == "major" { + 1 + } else if mode == "minor" { + 2 + } else if mode == "both" { + 3 + } else { + 0 + } +} + +/// Transform linear axis value to linear space (low, high) +#let _transform-lin(ax, value, low, high) = { + let range = high - low + + return low + (value - ax.min) * (range / (ax.max - ax.min)) +} + +/// Transform log axis value to linear space (low, high) +#let _transform-log(ax, value, low, high) = { + let range = high - low + + let f(x) = { + calc.log(calc.max(x, util.float-epsilon), base: ax.base) + } + + return low + (f(value) - f(ax.min)) * (range / (f(ax.max) - f(ax.min))) +} + +/// Linear Axis Constructor +#let linear(name, min, max, ..options) = ( + 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: 0, + compute-ticks: ticks.compute-ticks.with("lin"), +) + options.named() + +/// Log Axis Constructor +#let logarithmic(name, min, max, base, ..options) = ( + 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: 0, + compute-ticks: ticks.compute-ticks.with("log"), +) + options.named() + +// Prepare axis +#let prepare(ptx, ax) = { + ax.grid = _get-grid-mode(ax.grid) + if ax.min == none { ax.min = ax.auto-domain.at(0) } + if ax.max == none { ax.max = ax.auto-domain.at(1) } + if ax.min == none or ax.max == none { ax.min = -1e-6; ax.max = +1e-6 } + 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 +/// - low (number): Linear minimum +/// - high (number): Linear maximum +#let transform(ax, value, low, high) = { + return (ax.transform)(ax, value, low, high) +} diff --git a/src/compat.typ b/src/compat.typ new file mode 100644 index 0000000..3dd0a76 --- /dev/null +++ b/src/compat.typ @@ -0,0 +1,75 @@ +#import "/src/plot/util.typ" +#import "/src/cetz.typ" +#import cetz: draw + +#let make-cptx(ptx, old) = { + let axes = old.axes.map(name => ptx.axes.at(name)) + axes = axes.map(ax => { + if ax.min == none or ax.max == none { + // Compat elements need the axis domain very early + ax.min = ax.auto-domain.at(0) + ax.max = ax.auto-domain.at(1) + } + return ax + }) + return ( + axes: axes, + ) +} + +#let draw-old(ptx, transform, body) = { + if body != none { + (ctx => { + ctx.transform = ((1, 0, 0, 0), + (0,-1, 0, 0), + (0, 0, 1, 0), + (0, 0, 0, 1)) + let (ctx: _, drawables, bounds) = cetz.process.many(ctx, body) + drawables = cetz.drawable.apply-transform(v => { + let (x, y) = transform(v.slice(0, 2)).first() + return (x, y, 0) + }, drawables) + + return ( + ctx: ctx, + drawables: drawables, + ) + },) + } +} + +#let wrap(old) = { + return ( + priority: 0, + fn: ptx => { + let old = old + if "x-domain" in old { + ptx = util.set-auto-domain(ptx, (old.axes.at(0),), (old.x-domain,)) + } + if "y-domain" in old { + ptx = util.set-auto-domain(ptx, (old.axes.at(1),), (old.y-domain,)) + } + if "plot-prepare" in old { + old = (old.plot-prepare)(old, make-cptx(ptx, old)) + } + + let data = ( + axes: old.axes, + label: old.at("label", default: none), + style: old.at("style", default: none), + stroke: (ptx, transform) => { + if "plot-stroke" in old { + draw-old(ptx, transform, (old.plot-stroke)(old, make-cptx(ptx, old))) + } + }, + fill: (ptx, transform) => { + if "plot-fill" in old { + draw-old(ptx, transform, (old.plot-fill)(old, make-cptx(ptx, old))) + } + }, + ) + ptx.data.push(data) + return ptx + } + ) +} diff --git a/src/lib.typ b/src/lib.typ index 128ff08..78a0fbf 100644 --- a/src/lib.typ +++ b/src/lib.typ @@ -1,5 +1,4 @@ #let version = version(0,1,0) -#import "/src/axes.typ" #import "/src/plot.typ" #import "/src/chart.typ" diff --git a/src/plot.typ b/src/plot.typ index 78451f4..4fd65f9 100644 --- a/src/plot.typ +++ b/src/plot.typ @@ -1,8 +1,15 @@ -#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/axes.typ" -#import "/src/plot/sample.typ": sample-fn, sample-fn2 +#import "/src/axis.typ" +#import "/src/projection.typ" +#import "/src/spine.typ" +#import "/src/ticks.typ" +#import "/src/sub-plot.typ" +#import "/src/compat.typ" + +#import "/src/plot/sample.typ": sample, sample-int, sample-binary #import "/src/plot/line.typ": add, add-hline, add-vline, add-fill-between #import "/src/plot/contour.typ": add-contour #import "/src/plot/boxwhisker.typ": add-boxwhisker @@ -29,23 +36,52 @@ 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, ..options) = { + ((priority: -100, fn: (ptx) => { + ptx.axes.insert(name, axis.linear(name, min, max, ..options)) + 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, ..options) = { + ((priority: -100, fn: (ptx) => { + ptx.axes.insert(name, axis.logarithmic(name, min, max, base, ..options)) + return ptx }),) } + +#let templates = ( + scientific: (ptx) => { + lin-axis("x") + lin-axis("y") + //lin-axis("u") + //lin-axis("v") + sub-plot.new("x", "y") + }, + scientific-4: (ptx) => { + lin-axis("x") + lin-axis("y") + lin-axis("u") + lin-axis("v") + sub-plot.new("x", "y", "u", "v") + }, + school-book: (ptx) => { + lin-axis("x") + lin-axis("y") + sub-plot.new("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 +240,158 @@ /// - 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: (:), // Shared axes + plots: (), // Sub plots + data: (), // Plot data + legend: (), // Legend entries + anchors: (), // 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) - } - - // Setup data viewport - let data-viewport(data, all-axes, body, name: none) = { - if body == none or body == () { return } - - // Setup the viewport - axes.axis-viewport(all-axes, body, name: name) + if template != none and template in templates { + body = (templates.at(template))(ptx) + body } - 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 if cmd.type == "annotation" { - annotations.push(cmd) - } else if cmd.type == "context" { - ctx = (cmd.fn)(ctx) - } else { data.push(cmd) } - } - - assert(axis-style in (none, "scientific", "scientific-auto", "school-book", "left"), - message: "Invalid plot style") + // Wrap old style elements + body = body.map(elem => { + return if "type" in elem { + compat.wrap(elem) + } else { + elem + } + }) + 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) - // Create axes for data & annotations - for d in data + annotations { - if "axes" not in d { continue } + for elem in plot-elements.filter(elem => elem.priority <= 0) { + assert("fn" in elem, + message: "Invalid plot element: " + repr(elem)) - for (i, name) in d.axes.enumerate() { - assert(name in ctx.axes, message: "Undefined axis " + name) + ptx = (elem.fn)(ptx) + assert(ptx != none) + } - let axis = ctx.axes.at(name) - axis.used = true + // Apply axis options & prepare axes + ptx = plot-util.setup-axes(ptx, options.named()) - 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) + 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(calc.rem(i, plot-style.len())) + } else { + plot-style } - 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) + let data-style = data.at("style", default: (:)) + if type(data-style) == function { + data-style = (data-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 - ) - } + data.style = if style != none { + cetz.util.merge-dictionary(style, data-style) + } else { + data-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!") - } - } - - // 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) + return data + }) - if "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 matching-data = () + for proj in sub-plot.projections { + let axis-names = proj.axes.map(ax => ax.name) + let sub-data = ptx.data.filter(data => data.axes.all(ax => ax in axis-names)) + if sub-data != () { + matching-data.push((proj, sub-data)) } - }) - } - } - - 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) + } - if not fill-below and "plot-fill" in d { - (d.plot-fill)(d, plot-ctx) + // Draw background + for (proj, sub-data) in matching-data { + for data in sub-data { + draw.scope({ + draw.set-style(..data.style) + if fill-below { + (data.fill)(ptx, proj.transform) + } + }) + } } - if "plot-stroke" in d { - (d.plot-stroke)(d, plot-ctx) + + // Draw spine (axes, ticks, ...) + if sub-plot.at("spine", default: none) != none { + draw.group(name: sub-plot.spine.name, { + (sub-plot.spine.draw)(ptx) + }) } - }) - 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) - }) + // Draw foreground + for (proj, sub-data) in matching-data { + for data in sub-data { + draw.scope({ + draw.set-style(..data.style) + if not fill-below { + (data.fill)(ptx, proj.transform) + } + (data.stroke)(ptx, proj.transform) + }) + } + } } - } - - // 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 +420,27 @@ /// 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 { + for proj in plot.projections { + let axis-names = proj.axes.map(ax => ax.name) + if axes.all(name => axis-names.contains(name)) { + let position = position.enumerate().map(((i, v)) => { + return if v == "min" { + proj.axes.at(i).min + } else if v == "max" { + proj.axes.at(i).max + } else { + v + } + }) + let pt = (proj.transform)(position).first() + ptx.anchors.push((name, pt)) + } + } + } + return ptx + } ),) } diff --git a/src/plot/contour.typ b/src/plot/contour.typ index b03ed25..8e8adbd 100644 --- a/src/plot/contour.typ +++ b/src/plot/contour.typ @@ -287,6 +287,7 @@ y-domain: (0, 1), x-samples: 25, y-samples: 25, + sampler: auto, interpolate: true, op: auto, axes: ("x", "y"), @@ -294,11 +295,17 @@ fill: false, limit: 50, ) = { + if sampler == auto { + sampler = sample.sample-binary + .with(x-domain: x-domain, + y-domain: y-domain, + x-samples: x-samples, + y-samples: y-samples) + } + // Sample a x/y function if type(data) == function { - data = sample.sample-fn2(data, - x-domain, y-domain, - x-samples, y-samples) + data = sampler(data) } // Find matrix dimensions diff --git a/src/plot/legend.typ b/src/plot/legend.typ index 475209b..03091ca 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..b47f244 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 @@ -143,9 +100,9 @@ /// / `"shape"`: Fill the complete shape /// - samples (int): Number of times the `data` function gets called for /// sampling y-values. Only used if `data` is of type function. This parameter gets -/// passed onto `sample-fn`. +/// passed onto `sampler`. /// - sample-at (array): Array of x-values the function gets sampled at in addition -/// to the default sampling. This parameter gets passed to `sample-fn`. +/// to the default sampling. This parameter gets passed to `sampler`. /// - line (string, dictionary): Line type to use. The following types are /// supported: /// / `"raw"`: Plot raw data @@ -208,18 +165,26 @@ mark-style: (:), samples: 50, sample-at: (), + sampler: auto, line: "raw", axes: ("x", "y"), label: none, data ) = { + if sampler == auto { + sampler = sample.sample + .with(domain: domain, + samples: samples, + sample-at: sample-at) + } + // If data is of type function, sample it if type(data) == function { - data = sample.sample-fn(data, domain, samples, sample-at: sample-at) + data = sampler(data) } // Transform data - let line-data = transform-lines(data, line) + let line-data = _transform-lines(data, line) // Get x-domain let x-domain = ( @@ -233,31 +198,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 +278,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 +337,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 ),) } @@ -420,21 +400,29 @@ domain: auto, samples: 50, sample-at: (), + sampler: auto, line: "raw", axes: ("x", "y"), label: none, style: (:)) = { + if sampler == auto { + sampler = sample.sample + .with(domain: domain, + samples: samples, + sample-at: sample-at) + } + // If data is of type function, sample it if type(data-a) == function { - data-a = sample.sample-fn(data-a, domain, samples, sample-at: sample-at) + data-a = sampler(data-a) } if type(data-b) == function { - data-b = sample.sample-fn(data-b, domain, samples, sample-at: sample-at) + data-b = sampler(data-b) } // 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 +463,7 @@ } let fill(self, ctx) = { - fill-shape(self.fill-paths) + _fill-shape(self.fill-paths) } (( diff --git a/src/plot/mark.typ b/src/plot/mark.typ index f085305..2582e79 100644 --- a/src/plot/mark.typ +++ b/src/plot/mark.typ @@ -1,5 +1,4 @@ #import "/src/cetz.typ": draw -#import "/src/axes.typ" #import "/src/plot/util.typ" // Draw mark at point with size diff --git a/src/plot/sample.typ b/src/plot/sample.typ index 3ad881d..9bee6b6 100644 --- a/src/plot/sample.typ +++ b/src/plot/sample.typ @@ -13,7 +13,7 @@ /// to the `samples` number of samples. Values outsides the /// specified domain are legal. /// -> array: Array of (x, y) tuples -#let sample-fn(fn, domain, samples, sample-at: ()) = { +#let sample(fn, domain: (0,1), samples: 25, sample-at: ()) = { assert(samples + sample-at.len() >= 2, message: "You must at least sample 2 values") assert(type(domain) == array and domain.len() == 2, @@ -41,6 +41,28 @@ }) } +/// Sample the given single parameter function `samples` times (rounded down), +/// with $diam(domain)$ integer values. +/// And returns the sampled `y` value in an array as `(x, y)` tuples. +/// +/// If the functions first return value is a tuple `(x, y)`, then all return values +/// must be a tuple. +/// +/// - fn (function): Function to sample of the form `(x) => y` or `(t) => (x, y)`, where +/// `x` or `t` are `float` values within the domain specified by `domain`. +/// - domain (domain): Domain of `fn` used as bounding interval for the sampling points. +/// - sample-at (array): List of x values the function gets sampled at in addition +/// to the `samples` number of samples. Values outsides the +/// specified domain are legal. +/// -> array: Array of (x, y) tuples +#let sample-int(fn, domain: (0,1), sample-at: ()) = { + let (low, high) = domain.map(calc.floor) + let samples = high - low + 1 + + return sample(n => fn(int(n)), domain: domain, samples: samples, + sample-at: sample-at.map(calc.floor)) +} + /// Samples the given two parameter function with `x-samples` and /// `y-samples` values evenly spaced within the range given by /// `x-domain` and `y-domain` and returns each sampled output in @@ -54,7 +76,7 @@ /// - x-samples (int): Number of samples in the x-domain. /// - y-samples (int): Number of samples in the y-domain. /// -> array: Array of z scalars -#let sample-fn2(fn, x-domain, y-domain, x-samples, y-samples) = { +#let sample-binary(fn, x-domain: (0,1), y-domain: (0,1), x-samples: 25, y-samples: 25) = { assert(x-samples >= 2, message: "You must at least sample 2 x-values") assert(y-samples >= 2, diff --git a/src/plot/util.typ b/src/plot/util.typ index 70b5057..faf9895 100644 --- a/src/plot/util.typ +++ b/src/plot/util.typ @@ -2,6 +2,33 @@ #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)) + } + if min == max { + min -= float-epsilon + max += float-epsilon + } + 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 @@ -177,8 +204,24 @@ /// - axes (list): List of axes /// -> array List of stroke paths #let compute-stroke-paths(points, axes) = { + if not axes.any(ax => ax.at("clip", default: true)) { + return (points,) + } + let (x, y, ..) = axes - clipped-paths(points, (x.min, y.min), (x.max, y.max), fill: false) + let (x-min, x-max) = if x.at("clip", default: true) { + (x.min, x.max) + } else { + (-float.inf, float.inf) + } + + let (y-min, y-max) = if y.at("clip", default: true) { + (y.min, y.max) + } else { + (-float.inf, float.inf) + } + + clipped-paths(points, (x-min, y-min), (x-max, y-max), fill: false) } /// Compute clipped fill path @@ -187,8 +230,24 @@ /// - axes (list): List of axes /// -> array List of fill paths #let compute-fill-paths(points, axes) = { + if not axes.any(ax => ax.at("clip", default: true)) { + return (points,) + } + let (x, y, ..) = axes - clipped-paths(points, (x.min, y.min), (x.max, y.max), fill: true) + let (x-min, x-max) = if x.at("clip", default: true) { + (x.min, x.max) + } else { + (-float.inf, float.inf) + } + + let (y-min, y-max) = if y.at("clip", default: true) { + (y.min, y.max) + } else { + (-float.inf, float.inf) + } + + clipped-paths(points, (x-min, y-min), (x-max, y-max), fill: true) } /// Return points of a sampled catmull-rom through the @@ -269,111 +328,41 @@ return pts } -// Get the default axis orientation -// depending on the axis name -#let get-default-axis-horizontal(name) = { - return lower(name).starts-with("x") -} - // 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 + for (name, ax) in axes { + ax.min = get-opt(name, "min", ax.min) + ax.max = get-opt(name, "max", ax.max) + ax.clip = get-opt(name, "clip", ax.at("clip", default: true)) + 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) + + if get-opt(name, "mode", none) != none { + panic("Mode switching is no longer supported. Use log-axis/lin-axis to create the axis.") } - if changed { - axis-dict.at(name) = axis - } - } - - for (name, axis) in axis-dict { - axis-dict.at(name) = axes.prepare-axis(ctx, axis, name) + 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 new file mode 100644 index 0000000..caccbc1 --- /dev/null +++ b/src/projection.typ @@ -0,0 +1,50 @@ +#import "/src/cetz.typ": vector + +/// Create a new cartesian projection +/// +/// - low (vector): Lower viewport corner +/// - high (vector): Upper viewport corner +/// - axes (list): List of axes +/// -> function Transformation for one or more vectors +#let cartesian(low, high, axes) = { + return ( + axes: axes, + transform: (..v) => { + return v.pos().map(v => { + 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 + }) + }, + ) +} + +/// Create a new polar projection +/// +/// - low (vector): Lower viewport corner +/// - high (vector): Upper viewport corner +/// - start (angle): Start angle (0deg for full circle) +/// - stop (angle): Stop angle (360deg for full circle) +/// - axes (list): Axis array (angular, distal) +/// -> function Transformation for one or more vectors +#let polar(low, high, (angular, distal, ..), start: 0deg, stop: 360deg) = { + let center = vector.lerp(low, high, .5) + let radius = calc.min(..vector.sub(high, low).slice(0, 2)) / 2 + + return ( + axes: (angular, distal), + transform: (..v) => { + return v.pos().map(v => { + let theta = (angular.transform)(angular, v.at(0), start, stop) + let r = (distal.transform)(distal, v.at(1), 0, radius) + + vector.add(center, (calc.cos(theta) * r, calc.sin(theta) * r, 0)) + }) + }, + ) +} diff --git a/src/spine.typ b/src/spine.typ new file mode 100644 index 0000000..53ac482 --- /dev/null +++ b/src/spine.typ @@ -0,0 +1,3 @@ +#import "/src/spine/scientific.typ": scientific +#import "/src/spine/schoolbook.typ": schoolbook +#import "/src/spine/polar.typ": polar diff --git a/src/spine/polar.typ b/src/spine/polar.typ new file mode 100644 index 0000000..e7bc451 --- /dev/null +++ b/src/spine/polar.typ @@ -0,0 +1,68 @@ +#import "/src/cetz.typ" +#import "/src/axis.typ" +#import "/src/ticks.typ" +#import "/src/style.typ": prepare-style, get-axis-style, default-style +#import "/src/spine/util.typ": cartesian-axis-projection + +#import cetz: vector, draw + +/// Polar frame +#let polar(projections: none, name: none, ..style) = { + assert(projections.len() == 1, + message: "Unexpected number of projections!") + + return ( + name: name, + draw: (ptx) => { + let proj = projections.first() + let angular = proj.axes.at(0) + let distal = proj.axes.at(1) + + let (origin, start, mid, stop) = (proj.transform)( + (angular.min, distal.min), + (angular.min, distal.max), + ((angular.min + angular.max) / 2, distal.max), + (angular.max, distal.max), + ) + start = start.map(calc.round.with(digits: 6)) + stop = stop.map(calc.round.with(digits: 6)) + + let radius = vector.dist(origin, start) + + let style = prepare-style(ptx, cetz.styles.resolve(ptx.cetz-ctx.style, + root: "axes", merge: style.named(), base: style.default-style)) + let angular-style = get-axis-style(ptx, style, "angular") + let distal-style = get-axis-style(ptx, style, "distal") + + let r-padding = angular-style.padding.first() + let r-start = origin + let r-end = vector.add(origin, (0, radius)) + draw.line(r-start, (rel: (0, radius + r-padding)), stroke: distal-style.stroke) + if "computed-ticks" in distal { + // TODO + ticks.draw-distal-grid(proj, distal.computed-ticks, distal-style) + //ticks.draw-cartesian(r-start, r-end, distal.computed-ticks, distal-style) + } + + if start == stop { + draw.circle(origin, radius: radius + r-padding, + stroke: angular-style.stroke, + fill: angular-style.fill) + } else { + // Apply padding to all three points + (start, mid, stop) = (start, mid, stop).map(pt => { + vector.add(pt, vector.scale(vector.norm(vector.sub(pt, origin)), r-padding)) + }) + + draw.arc-through(start, mid, stop, + stroke: angular-style.stroke, + fill: angular-style.fill, + mode: "PIE") + } + if "computed-ticks" in angular { + ticks.draw-angular-grid(proj, angular.computed-ticks, angular-style) + // TODO + } + }, + ) +} diff --git a/src/spine/schoolbook.typ b/src/spine/schoolbook.typ new file mode 100644 index 0000000..9e6c24b --- /dev/null +++ b/src/spine/schoolbook.typ @@ -0,0 +1,100 @@ +#import "/src/cetz.typ" +#import "/src/axis.typ" +#import "/src/ticks.typ" +#import "/src/style.typ": prepare-style, get-axis-style, default-style +#import "/src/spine/util.typ": cartesian-axis-projection +#import "/src/spine/grid.typ" + +#import cetz: vector, draw + +/// 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 schoolbook(projections: none, name: none, zero: (0, 0), ..style) = { + return ( + name: name, + draw: (ptx) => { + let proj = projections.at(0) + let axes = proj.axes + let x = axes.at(0) + let y = axes.at(1) + let z = axes.at(2, 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-x = calc.max(x.min, calc.min(0, x.max)) + let zero-y = calc.max(y.min, calc.min(0, y.max)) + let zero-pt = ( + calc.max(x.min, calc.min(zero.at(0), x.max)), + calc.max(y.min, calc.min(zero.at(1), y.max)), + ) + + let (zero, min-x, max-x, min-y, max-y) = (proj.transform)( + zero-pt, + vector.add(zero-pt, (x.min, zero-y)), vector.add(zero-pt, (x.max, zero-y)), + vector.add(zero-pt, (zero-x, y.min)), vector.add(zero-pt, (zero-x, y.max)), + ) + + 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 { + let grid-offset = min-x.at(1) - min-y.at(1) + let grid-length = max-y.at(1) - min-y.at(1) + let tick-proj = cartesian-axis-projection(x, min-x, max-x) + grid.draw-cartesian(tick-proj, grid-offset, grid-length, (0,1), x.computed-ticks, style.grid, x.grid) + ticks.draw-cartesian(tick-proj, (0,+1), 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 { + let grid-offset = min-y.at(0) - min-x.at(0) + let grid-length = max-x.at(0) - min-x.at(0) + let tick-proj = cartesian-axis-projection(y, min-y, max-y) + grid.draw-cartesian(tick-proj, grid-offset, grid-length, (1,0), y.computed-ticks, style.grid, y.grid) + ticks.draw-cartesian(tick-proj, (+1,0), y.computed-ticks, y-style) + } + } + ) +} diff --git a/src/spine/scientific.typ b/src/spine/scientific.typ new file mode 100644 index 0000000..e1b3e82 --- /dev/null +++ b/src/spine/scientific.typ @@ -0,0 +1,120 @@ +#import "/src/cetz.typ" +#import "/src/axis.typ" +#import "/src/ticks.typ" +#import "/src/style.typ": prepare-style, get-axis-style, default-style +#import "/src/spine/util.typ": cartesian-axis-projection +#import "/src/spine/grid.typ" + +#import cetz: vector, draw + +/// +#let scientific(projections: none, name: none, style: (:)) = { + return ( + name: name, + draw: (ptx) => { + let xy-proj = projections.at(0) + let uv-proj = projections.at(1, default: xy-proj) + let has-uv = projections.len() > 1 + let (x, y) = xy-proj.axes + let (u, v) = uv-proj.axes + + 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.name) + let y-style = get-axis-style(ptx, style, y.name) + let u-style = get-axis-style(ptx, style, u.name) + let v-style = get-axis-style(ptx, style, v.name) + + let (x-low, x-high, y-low, y-high) = (xy-proj.transform)( + (x.min, y.min), (x.max, y.min), + (x.min, y.min), (x.min, y.max), + ) + let (u-low, u-high, v-low, v-high) = (uv-proj.transform)( + (u.min, v.max), (u.max, v.max), + (u.max, v.min), (u.max, v.max), + ) + + let move-vec(v, direction, length) = { + vector.add(v, direction.enumerate().map(((i, v)) => v * length.at(i))) + } + + // Outset axes + x-low = move-vec(x-low, (0, -1), x-style.padding) + x-high = move-vec(x-high, (0, -1), x-style.padding) + y-low = move-vec(y-low, (-1, 0), y-style.padding) + y-high = move-vec(y-high, (-1, 0), y-style.padding) + u-low = move-vec(u-low, (0, 1), u-style.padding) + u-high = move-vec(u-high, (0, 1), u-style.padding) + v-low = move-vec(v-low, (1, 0), v-style.padding) + v-high = move-vec(v-high, (1, 0), v-style.padding) + + // Frame corners (FIX for uv axes) + let south-west = move-vec(x-low, (-1, 0), x-style.padding) + let south-east = move-vec(x-high, (+1, 0), x-style.padding) + let north-west = move-vec(u-low, (-1, 0), u-style.padding) + let north-east = move-vec(u-high, (+1, 0), u-style.padding) + + // Grid lengths + let x-grid-length = u-low.at(1) - x-low.at(1) + let y-grid-length = v-low.at(0) - y-low.at(0) + let u-grid-length = u-low.at(1) - x-low.at(1) + let v-grid-length = v-low.at(0) - y-low.at(0) + + let axes = ( + (x, (0,+1), x-grid-length, cartesian-axis-projection(x, x-low, x-high), x-style, false), + (y, (+1,0), y-grid-length, cartesian-axis-projection(y, y-low, y-high), y-style, false), + (u, (0,-1), u-grid-length, cartesian-axis-projection(u, u-low, u-high), u-style, not has-uv), + (v, (-1,0), v-grid-length, cartesian-axis-projection(v, v-low, v-high), v-style, not has-uv), + ) + + draw.group(name: "spine", { + for (ax, dir, grid-length, proj, style, mirror) in axes { + if "computed-ticks" in ax { + if not mirror { + grid.draw-cartesian(proj, 0, grid-length, dir, ax.computed-ticks, style.grid, ax.grid) + } + ticks.draw-cartesian(proj, dir, ax.computed-ticks, style, is-mirror: mirror) + } + } + for (ax, dir, grid-length, proj, style, mirror) in axes { + draw.on-layer(style.axis-layer, { + draw.line(proj(ax.min), proj(ax.max), stroke: style.stroke, mark: style.mark) + }) + } + }) + + let label-config = ( + ("south", "north", 0deg), + ("west", "south", 90deg), + ("north", "south", 0deg), + ("east", "north", 90deg), + ) + for (i, (side, default-anchor, default-angle)) in label-config.enumerate() { + let (ax, dir, _, proj, style, mirror) = axes.at(i) + if not mirror and ax.label != none and ax.label != [] { + let pos = proj((ax.max + ax.min) / 2) + let offset = vector.scale(dir, -style.label.offset) + let is-horizontal = calc.rem(i, 2) == 0 + pos = if is-horizontal { + (pos, "|-", (rel: offset, to: "spine." + side)) + } else { + (pos, "-|", (rel: offset, to: "spine." + side)) + } + + let angle = style.label.angle + if angle == auto { + angle = default-angle + } + + let anchor = style.label.anchor + if anchor == auto { + anchor = default-anchor + } + + draw.content(pos, + [#ax.label], anchor: anchor, angle: angle) + } + } + }, + ) +} diff --git a/src/spine/util.typ b/src/spine/util.typ new file mode 100644 index 0000000..79e49cf --- /dev/null +++ b/src/spine/util.typ @@ -0,0 +1,12 @@ +#import "/src/cetz.typ": vector +#import "/src/axis.typ" + +/// Returns a function that interpolates from an axis value +/// between start and stop +#let cartesian-axis-projection(ax, start, stop) = { + let dir = vector.norm(vector.sub(stop, start)) + let dist = vector.dist(start, stop) + return (value) => { + vector.add(start, vector.scale(dir, axis.transform(ax, value, 0, dist))) + } +} diff --git a/src/style.typ b/src/style.typ new file mode 100644 index 0000000..73cb525 --- /dev/null +++ b/src/style.typ @@ -0,0 +1,157 @@ +#import "/src/cetz.typ" +#import cetz: draw + +/// 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: gray + .5pt, + minor-stroke: gray + .25pt, + ), + + label: ( + angle: auto, + offset: .5em, + anchor: auto, + ), + + // Overrides + x: ( + tick: ( + label: ( + anchor: "north", + ), + ), + ), + y: ( + tick: ( + label: ( + anchor: "east", + ), + ), + ), + u: ( + tick: ( + label: ( + anchor: "south", + ), + ), + ), + v: ( + tick: ( + label: ( + anchor: "west", + ), + ), + ), + distal: ( + tick: ( + label: ( + anchor: "east", + ) + ) + ), +) + +#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) + + style.label.offset = resolve-number(style.label.offset) + + return style +} + +/// Get merged (sub) style for an axis +#let get-axis-style(ptx, style, name) = { + return prepare-style(ptx, if name in style { + cetz.util.merge-dictionary(style, style.at(name, default: (:))) + } else { + style + }) +} diff --git a/src/sub-plot.typ b/src/sub-plot.typ new file mode 100644 index 0000000..6ed2491 --- /dev/null +++ b/src/sub-plot.typ @@ -0,0 +1,51 @@ +#import "/src/spine.typ" +#import "/src/projection.typ" +#import "/src/cetz.typ" + +/// Create a new sub-plot +/// +/// - spine (spine): The sub-plot spine +/// - projection (projection): The projection to use +/// - origin (vector): Origin +/// - size (auto,vector): Size or auto to use plot default size +/// - ..axes-style (axis,style): Positionaln axis names (string) and style keys +#let new(spine: spine.scientific, projection: projection.cartesian, origin: (0, 0), size: auto, ..axes-style) = { + let axes = axes-style.pos() + let style = axes-style.named() + + assert(axes.len() > 0, + message: "Axes must be set!") + assert(calc.rem(axes.len(), 2) == 0, + message: "Axes must be a multiple of two!") + + (( + priority: 100, + fn: ptx => { + let (_, origin) = cetz.coordinate.resolve(ptx.cetz-ctx, origin) + let size = size + if size == auto { + size = ptx.default-size + } + size = size.map(cetz.util.resolve-number.with(ptx.cetz-ctx)).map(calc.abs) + size = cetz.vector.add(origin, size) + + let axes = axes.map(name => ptx.axes.at(name)) + let axis-pairs = axes.chunks(2, exact: true) + let projections = axis-pairs.map(((x, y)) => { + (projection)(origin, size, (x, y)) + }) + + let spine = if spine != none { + spine( + projections: projections, + ) + } + + ptx.plots.push(( + spine: spine, + projections: projections, + )) + return ptx + } + ),) +} diff --git a/src/ticks.typ b/src/ticks.typ new file mode 100644 index 0000000..88ee9be --- /dev/null +++ b/src/ticks.typ @@ -0,0 +1,398 @@ +#import "/src/cetz.typ" +#import cetz: util, vector, draw + +#import "/src/plot/formats.typ" + +// TODO: Remove +#let _get-grid-mode(mode) = { + return if mode in (true, "major") { + 1 + } else if mode == "minor" { + 2 + } else if mode == "both" { + 3 + } else { + 0 + } +} + +// TODO: Remove +#let _draw-grid(mode, is-major) = { + return (2 + int(is-major)).bit-and(mode) +} + +// 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 +} + +#let clip-ticks(axis, ticks) = { + let (min, max) = (axis.min, axis.max) + let err = util.float-epsilon + /* + return ticks.filter(((value, ..)) => { + min - err <= value and value <= max + err + }) + */ + return ticks +} + +/// Compute list of linear ticks +/// +/// - ax (axis): Axes +/// -> List of ticks +#let compute-linear-ticks(ax) = { + let compute-list(min, max, step, limit) = { + if step == none or step <= 0 or min == none or max == none { + return () + } + + let num-negative = int((0 - min) / step) + let num-positive = int((max - 0) / step) + + if num-negative + num-positive > limit { + panic("Tick limit reached! Adjust axis ticks.limit or ticks.minor-limit.") + } + + return range(-num-negative, num-positive + 1).map(t => { + t * step + }) + } + + let major-limit = ax.at("tick-limit", default: 100) + let minor-limit = ax.at("minor-tick-limit", default: 1000) + + let major = compute-list(ax.min, ax.max, ax.ticks.step, major-limit) + let minor = compute-list(ax.min, ax.max, ax.ticks.minor-step, minor-limit) + + minor.map(value => { + (value, none, false) + }) + major.map(value => { + (value, format-tick-value(value, ax.ticks), true) + }) +} + +/// Compute list of logarithmic ticks +/// +/// - ax (axis): Axis +/// -> List of ticks +#let compute-logarithmic-ticks(ax) = { + let min = calc.log(calc.max(ax.min, util.float-epsilon), base: ax.base) + let max = calc.log(calc.max(ax.max, util.float-epsilon), base: ax.base) + + let compute-list(min, max, step, limit) = { + if step == none or step <= 0 or min == none or max == none { + return () + } + + let num-positive = int((max - 0) / step) + + if num-positive > limit { + panic("Tick limit reached! Adjust axis ticks.limit or ticks.minor-limit.") + } + + // TODO + + return () + } + + let major-limit = ax.at("tick-limit", default: 100) + let minor-limit = ax.at("minor-tick-limit", default: 1000) + + let major = compute-list(ax.min, ax.max, ax.ticks.step, major-limit) + let minor = compute-list(ax.min, ax.max, ax.ticks.minor-step, minor-limit) + + minor.map(value => { + (value, none, false) + }) + major.map(value => { + (value, format-tick-value(value, ax.ticks), true) + }) +} + +// Compute list of linear ticks for axis +// +// - axis (axis): Axis +#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), + calc.log(calc.max(axis.max, ferr), base: axis.base) + ) + let dt = max - min; if (dt == 0) { dt = 1 } + let ticks = axis.ticks + + 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 { + let major-tick-values = () + if "step" in ticks and ticks.step != none { + assert(ticks.step >= 0, + message: "Axis tick step must be positive and non 0.") + if axis.min > axis.max { ticks.step *= -1 } + + let s = 1 / ticks.step + + let num-ticks = int(max * s + 1.5) - int(min * s) + assert(num-ticks <= tick-limit, + message: "Number of major ticks exceeds limit " + str(tick-limit)) + + } + + if "minor-step" in ticks and ticks.minor-step != none { + assert(ticks.minor-step >= 0, + message: "Axis minor tick step must be positive") + if axis.min > axis.max { ticks.minor-step *= -1 } + + let s = 1 / ticks.step + let n = range(int(min * s)-1, int(max * s + 1.5)+1) + + for t in n { + for vv in range(1, int(axis.base / ticks.minor-step)) { + + let v = ( (calc.log(vv * ticks.minor-step, base: axis.base) + t)/ s - min) / dt + if v in major-tick-values {continue} + + if v != none and v >= 0 and v <= 1 + ferr { + l.push((v, none, false)) + } + + } + + } + } + } + + return l +} + +// Get list of fixed axis ticks +// +// - axis (axis): Axis object +#let fixed-ticks(ax) = { + let list = ax.at("list", default: none) + if type(list) == function { + list = (list)(ax) + } + if type(list) != array { + return () + } + + 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 + } + + if v != none { + return (v, label, true) + } + }).filter(v => v != none) +} + +// Compute list of axis ticks +// +// 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(mode, axis) = { + 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(ax, n: 11) = { + if ax.min == none or ax.max == none { + return none + } + + let dt = calc.abs(ax.max - ax.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 auto-tick-factors { + s = s * calc.pow(10, scale) + + let divs = calc.abs(dt / s) + if divs >= best and divs <= n { + step = s + best = divs + } + } + return step + } + + if axis == none or axis.ticks == none { return () } + if axis.ticks.step == auto { + 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 { + axis.ticks.step / 5 + } else { + none + } + } + + let ticks = if mode == "log" { + compute-logarithmic-ticks(axis) + } else { + compute-linear-ticks(axis) + } + ticks += fixed-ticks(axis) + return ticks +} + +// Place a list of tick marks and labels along a line +#let draw-cartesian(transform, norm, ticks, style, is-mirror: false, show-zero: true) = { + let draw-label = style.tick.label.draw + + draw.on-layer(style.tick-layer, { + 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 (value, 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 = transform(value) + 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 angular polar grid +#let draw-angular-grid(projection, ticks, style) = { + let (angular, distal, ..) = projection.axes + let mode = _get-grid-mode(distal.grid) + if mode == 0 { + return + } + + let (origin,) = (projection.transform)( + (angular.min, distal.min), + ) + + let padding = style.padding.first() + let range = angular.max - angular.min + + draw.on-layer(style.grid-layer, { + for (pos, _, is-major) in ticks { + if not _draw-grid(mode, is-major) { + continue + } + + let (pos,) = (projection.transform)( + (angular.min + pos * range, distal.max), + ) + + pos = vector.add(pos, vector.scale(vector.norm(vector.sub(pos, origin)), padding)) + + draw.line(origin, pos, + stroke: if is-major { style.grid.stroke } else { style.grid.minor-stroke }) + } + }) +} + +/// Draw distal polar grid +#let draw-distal-grid(projection, ticks, style) = { + let (angular, distal, ..) = projection.axes + let mode = _get-grid-mode(distal.grid) + if mode == 0 { + return + } + + let (origin, start, stop) = (projection.transform)( + (angular.min, distal.min), + (angular.min, distal.max), + (angular.max, distal.max), + ).map(v => v.map(calc.round.with(digits: 6))) + + let is-arc = start != stop + let radius = vector.dist(origin, start) + let range = distal.max - distal.min + + let draw-ring = (position, stroke) => { + let v = distal.min + position * range + if distal.min < v and v < distal.max { + if not is-arc { + draw.circle(origin, radius: radius / range * v, + stroke: stroke, + fill: none) + } else { + let (start, mid, stop) = (projection.transform)( + (angular.min, v), + ((angular.min + angular.max) / 2, v), + (angular.max, v) + ) + + draw.arc-through(start, mid, stop, + stroke: stroke, + fill: none) + } + } + } + + draw.on-layer(style.grid-layer, { + for (pos, _, is-major) in ticks { + if not _draw-grid(mode, is-major) { + continue + } + + draw-ring(pos, if is-major { style.grid.stroke } else { style.grid.minor-stroke }) + } + }) +} diff --git a/tests/axes/log-mode/test.typ b/tests/axes/log-mode/test.typ index 0860810..8147b0d 100644 --- a/tests/axes/log-mode/test.typ +++ b/tests/axes/log-mode/test.typ @@ -3,33 +3,29 @@ #import "/tests/helper.typ": * #import "/src/lib.typ": * #import cetz: draw, canvas -#import cetz-plot: axes, #test-case({ - import draw: * - plot.plot( size: (9, 6), - axis-style: "scientific", - y-mode: "log", y-base: 10, y-format: "sci", x-min: 1, x-max: 10, x-tick-step: 1, y-min: 1, y-max: 10000, y-tick-step: 1, y-minor-tick-step: 1, x-grid: "both", y-grid: "both", { + plot.log-axis("y", base: 10) + plot.add( domain: (0, 10), x => {calc.pow(10, x)}, samples: 100, - line: "raw", label: $ y=10^x $, ) + plot.add( domain: (1, 10), x => {x}, samples: 100, - line: "raw", hypograph: true, label: $ y=x $, ) @@ -37,6 +33,24 @@ ) }) +// Column chart test +#test-case({ + plot.plot( + size: (9, 6), + y-format: "sci", + x-min: -0.5, x-max: 4.5, x-tick-step: 1, + y-min: 0.1, y-max: 10000, step: 1, minor-step: 1, + x-grid: "both", + y-grid: "both", + { + plot.log-axis("y") + plot.add-bar( + (1, 10, 100, 1000, 10000).enumerate().map(((x,y))=>{(x,y)}), + bar-width: 0.8, + ) + } + ) +}) // Bode plot test #box(stroke: 2pt + red,{ canvas({ @@ -47,15 +61,14 @@ ) plot.plot( size: (16, 6), - axis-style: "scientific", x-format: none, x-label: none, - x-mode: "log", x-min: 0.01, x-max: 100, x-tick-step: 1, x-minor-tick-step: 1, y-label: [Magnitude ($upright(d B)$)], y-min: -40, y-max: 10, y-tick-step: 10, x-grid: "both", y-grid: "both", { + plot.log-axis("x") plot.add(domain: (0.01, 100), x => {0}) } ) @@ -68,8 +81,6 @@ ) plot.plot( size: (16, 6), - axis-style: "scientific", - x-mode: "log", x-min: 0.01, x-max: 100, x-tick-step: 1, x-minor-tick-step: 1, x-label: [Frequency ($upright(r a d)\/s$)], y-label: [Phase ($upright(d e g)$)], @@ -77,33 +88,14 @@ x-grid: "both", y-grid: "both", { + plot.log-axis("x") plot.add(domain: (0.01, 100), x => {-40}) } ) }) }) -// Column chart test -#box(stroke: 2pt + red, canvas({ - import draw: * - - plot.plot( - size: (9, 6), - axis-style: "scientific", - y-mode: "log", y-base: 10, - y-format: "sci", - x-min: -0.5, x-max: 4.5, x-tick-step: 1, - y-min: 0.1, y-max: 10000, y-tick-step: 1, y-minor-tick-step: 1, - x-grid: "both", - y-grid: "both", - { - plot.add-bar( - (1, 10, 100, 1000, 10000).enumerate().map(((x,y))=>{(x,y)}), - bar-width: 0.8, - ) - } - ) -})) +/* // Scatter plot test #box(stroke: 2pt + red, canvas({ @@ -111,14 +103,13 @@ plot.plot( size: (9, 6), - axis-style: "scientific", - y-mode: "log", y-base: 100, y-format: "sci", x-min: -0.5, x-max: 4.5, x-tick-step: 1, y-min: 0.1, y-max: 10000, y-tick-step: 1, y-minor-tick-step: 10, x-grid: "both", y-grid: "both", { + plot.log-axis("y", base: 100) plot.add( ((0, 1),(1,2),(1,3),(2, 100),(2,150),(3, 1000),), style: (stroke: none), @@ -135,21 +126,21 @@ } ) })) +*/ // Box plot test -#box(stroke: 2pt + red, canvas({ +#test-case({ import draw: * plot.plot( size: (9, 6), - axis-style: "scientific", - y-mode: "log", y-base: 10, y-format: "sci", x-min: -0.5, x-max: 2.5, x-tick-step: 1, y-min: 0.1, y-max: 15000, y-tick-step: 1, y-minor-tick-step: 1, x-grid: "both", y-grid: "both", { + plot.log-axis("y") plot.add-boxwhisker( ( (x: 0, min: 1, q1: 10, q2: 100, q3: 1000, max: 10000), @@ -159,4 +150,4 @@ ) } ) -})) +}) diff --git a/tests/axes/ref/1.png b/tests/axes/ref/1.png deleted file mode 100644 index fbc0603..0000000 Binary files a/tests/axes/ref/1.png and /dev/null differ diff --git a/tests/axes/test.typ b/tests/axes/test.typ deleted file mode 100644 index a4b6153..0000000 --- a/tests/axes/test.typ +++ /dev/null @@ -1,65 +0,0 @@ -#set page(width: auto, height: auto) -#import "/tests/helper.typ": * -#import cetz: draw -#import cetz-plot: axes, plot - -// Schoolbook Axis Styling -#test-case({ - import draw: * - - set-style(axes: ( - stroke: blue, - padding: .25, - x: (stroke: red), - y: (stroke: green, tick: (stroke: blue, length: .3)) - )) - axes.school-book(size: (6, 6), - axes.axis(min: -1, max: 1, ticks: (step: 1, minor-step: auto, - grid: "both")), - axes.axis(min: -1, max: 1, ticks: (step: .5, minor-step: auto, - grid: "major"))) -}) - -// Scientific Axis Styling -#test-case({ - import draw: * - - set-style(axes: (stroke: blue)) - set-style(axes: (left: (tick: (stroke: green + 2pt)))) - set-style(axes: (bottom: (tick: (stroke: red, length: .5, - label: (angle: 90deg, - anchor: "east"))))) - set-style(axes: (right: (tick: (label: (offset: .2, - angle: -45deg, - anchor: "north-west"), length: -.1)))) - axes.scientific(size: (6, 6), - draw-unset: false, - top: none, - bottom: axes.axis(min: -1, max: 1, ticks: (step: 1, minor-step: auto, - grid: "both", format: plot.formats.decimal.with(prefix: $<-$, suffix: $->$))), - left: axes.axis(min: -1, max: 1, ticks: (step: .5, minor-step: auto, - grid: false)), - right: axes.axis(min: -10, max: 10, ticks: (step: auto, minor-step: auto, - grid: "major")),) -}) - -// Custom Tick Format -#test-case({ - import draw: * - - axes.scientific(size: (6, 1), - bottom: axes.axis(min: -2*calc.pi, max: 2*calc.pi, ticks: ( - step: calc.pi, minor-step: auto, format: plot.formats.multiple-of.with(symbol: $pi$), - )), - left: axes.axis(min: -1, max: 1, ticks: (step: none, minor-step: none))) -}) - -// #10 - Minor ticks on reversed axis -#test-case({ - import draw: * - - axes.scientific(size: (6, 1), - bottom: axes.axis(min: 5, max: -5, - ticks: (step: 5, minor-step: 1)), - left: axes.axis(min: -1, max: 1, ticks: (step: none, minor-step: none))) -}) diff --git a/tests/plot/comb/test.typ b/tests/plot/comb/test.typ index 36dc317..e3d5f6c 100644 --- a/tests/plot/comb/test.typ +++ b/tests/plot/comb/test.typ @@ -141,15 +141,15 @@ Uniform marks across the series ) }) -= Logarithym // Test pending upstream #test-case({ plot.plot( size: (10,6), // x-min: 35, x-max: 45, y-max: 100, - y-mode: "log", y-tick-step: 1, y-base: 10, y-format: "sci", y-minor-tick-step: 1, + y-tick-step: 1, y-base: 10, y-format: "sci", y-minor-tick-step: 1, { + plot.log-axis("y") plot.add-comb( label: "Linalool, 70eV", mark: "o", @@ -166,8 +166,9 @@ Uniform marks across the series size: (10,6), x-min: 10, x-max: 1000, y-max: 100, y-tick-step: 20, - x-mode: "log", x-tick-step: 1, x-base: 10, x-format: "sci", + x-tick-step: 1, x-base: 10, x-format: "sci", { + plot.log-axis("x") plot.add-comb( label: "Linalool, 70eV", mark: "x", @@ -176,4 +177,4 @@ Uniform marks across the series ) } ) -}) \ No newline at end of file +}) diff --git a/tests/plot/equal-axis/test.typ b/tests/plot/equal-axis/test.typ index a9b0a8f..b81b05e 100644 --- a/tests/plot/equal-axis/test.typ +++ b/tests/plot/equal-axis/test.typ @@ -12,8 +12,8 @@ x-equal: "y", b-equal: "a", { - plot.add-cartesian-axis("a", (0,0), (6,0)) - plot.add-cartesian-axis("b", (0,0), (0,3)) + plot.lin-axis("a") + plot.lin-axis("b") plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t))) plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t)), axes: ("a", "b")) @@ -29,8 +29,8 @@ x-equal: "y", b-equal: "a", { - plot.add-cartesian-axis("a", (0,0), (3,0)) - plot.add-cartesian-axis("b", (0,0), (0,6)) + plot.lin-axis("a") + plot.lin-axis("b") plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t))) plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t)), axes: ("a", "b")) diff --git a/tests/plot/format/ref/1.png b/tests/plot/format/ref/1.png index a7a2925..a6c5372 100644 Binary files a/tests/plot/format/ref/1.png and b/tests/plot/format/ref/1.png differ diff --git a/tests/plot/grid/ref/1.png b/tests/plot/grid/ref/1.png index 49e291b..6e32948 100644 Binary files a/tests/plot/grid/ref/1.png and b/tests/plot/grid/ref/1.png differ diff --git a/tests/plot/marks/test.typ b/tests/plot/marks/test.typ index aa584f0..ed144de 100644 --- a/tests/plot/marks/test.typ +++ b/tests/plot/marks/test.typ @@ -7,7 +7,7 @@ #test-case({ import cetz-plot: plot - let axis-options = (("x", "y"), ("x2", "y"), ("x", "y2"), ("x2", "y2")) + let axis-options = (("x", "y"), ("u", "y"), ("x", "v"), ("u", "v")) plot.plot( size: (5,5), diff --git a/tests/plot/sample/sample.typ b/tests/plot/sample/sample.typ index 54d617e..132c82b 100644 --- a/tests/plot/sample/sample.typ +++ b/tests/plot/sample/sample.typ @@ -7,7 +7,7 @@ (samples: 2, res: ((0,0), (50,5.0), (60,6.0), (100,10)), extra: (50,60)), ) #for c in cases { - let pts = plot.sample-fn(x => x/10, (0, 100), c.samples, + let pts = plot.sample(x => x/10, (0, 100), samples: c.samples, sample-at: c.at("extra", default: ())) assert.eq(pts, c.res, message: "Expected: " + repr(c.res) + ", got: " + repr(pts)) @@ -21,8 +21,9 @@ (100,150,200))), ) #for c in cases { - let rows = plot.sample-fn2((x, y) => x + y, (0, 100), (0,100), - c.samples.at(0), c.samples.at(1)) + let rows = plot.sample-binary((x, y) => x + y, x-domain: (0, 100), y-domain: (0,100), + x-samples: c.samples.at(0), + y-samples: c.samples.at(1)) assert.eq(rows, c.res, message: "Expected: " + repr(c.res) + ", got: " + repr(rows)) } diff --git a/tests/plot/test.typ b/tests/plot/test.typ index 4cf0944..3badb53 100644 --- a/tests/plot/test.typ +++ b/tests/plot/test.typ @@ -28,26 +28,26 @@ x-min: -360, x-max: 360, y-tick-step: 1, - x2-label: none, - x2-min: -90, - x2-max: 90, - x2-tick-step: 45, - x2-minor-tick-step: 15, - y2-label: none, - y2-min: -1.5, - y2-max: 1.5, - y2-tick-step: .5, - y2-minor-tick-step: .1, + u-label: none, + u-min: -90, + u-max: 90, + u-tick-step: 45, + u-minor-tick-step: 15, + v-label: none, + v-min: -1.5, + v-max: 1.5, + v-tick-step: .5, + v-minor-tick-step: .1, { plot.add(data) - plot.add(data, style: (stroke: blue), axes: ("x2", "y2")) + plot.add(data, style: (stroke: blue), axes: ("u", "v")) }) }) /* School-Book Style */ #test-case({ plot.plot(size: (5, 4), - axis-style: "school-book", + template: "school-book", x-tick-step: 180, y-tick-step: 1, { @@ -58,7 +58,7 @@ /* Clipping */ #test-case({ plot.plot(size: (5, 4), - axis-style: "school-book", + template: "school-book", x-min: auto, x-max: 350, x-tick-step: 180, @@ -75,7 +75,6 @@ plot.plot(size: (5, 4), x-label: [Rainbow], x-tick-step: none, - axis-style: "scientific", y-label: [Color], y-max: 8, y-tick-step: none, @@ -92,34 +91,33 @@ /* Tick Step Calculation */ #test-case({ plot.plot(size: (12, 4), - y2-decimals: 4, + v-format: plot.formats.decimal.with(digits: 4), { plot.add(((0,0), (1,10)), axes: ("x", "y")) - plot.add(((0,0), (.1,.01)), axes: ("x2", "y2")) + plot.add(((0,0), (.1,.01)), axes: ("u", "v")) }) }) #test-case({ plot.plot(size: (12, 4), - y2-decimals: 9, - x2-decimals: 9, - y2-format: "sci", + v-format: plot.formats.sci, + u-format: plot.formats.sci, { plot.add(((0,0), (30,2500)), axes: ("x", "y")) - plot.add(((0,0), (.001,.0001)), axes: ("x2", "y2")) + plot.add(((0,0), (.001,.0001)), axes: ("u", "v")) }) }) -/* Axis Styles */ - - +/* Templates */ #test-case(args => { plot.plot(size: (4,4), x-tick-step: 90, y-tick-step: 1, - axis-style: args, { + template: args, { plot.add(domain: (0, 360), x => calc.sin(x * 1deg)) }) }, args: ( - "scientific", "scientific-auto", "left", "school-book", none + // TODO + //"scientific", "scientific-auto", "left", "school-book", none + "scientific", "school-book" )) /* Manual Axis Bounds */ @@ -136,10 +134,10 @@ yb-min: -1.5, yb-max: .5, yt-min: -.5, yt-max: 1.5, { - plot.add-cartesian-axis("xl", (0, 0), (4, 0)) - plot.add-cartesian-axis("xr", (0, 4), (4, 4)) - plot.add-cartesian-axis("yt", (0, 0), (0, 4)) - plot.add-cartesian-axis("yb", (4, 0), (4, 4)) + plot.lin-axis("xl") + plot.lin-axis("xr") + plot.lin-axis("yt") + plot.lin-axis("yb") plot.add(circle-data) plot.add(circle-data, axes: ("xl", "y"), style: (stroke: green)) plot.add(circle-data, axes: ("xr", "y"), style: (stroke: red)) @@ -159,17 +157,17 @@ yb-min: -1.75, yb-max: .25, yt-min: -.25, yt-max: 1.75, { - plot.add-cartesian-axis("xl", (0, 0), (4, 0)) - plot.add-cartesian-axis("xr", (0, 4), (4, 4)) - plot.add-cartesian-axis("yt", (0, 0), (0, 4)) - plot.add-cartesian-axis("yb", (4, 0), (4, 4)) + plot.lin-axis("xl") + plot.lin-axis("xr") + plot.lin-axis("yt") + plot.lin-axis("yb") plot.add(circle-data) plot.add(circle-data, axes: ("xl", "y"), style: (stroke: green)) plot.add(circle-data, axes: ("xr", "y"), style: (stroke: red)) plot.add(circle-data, axes: ("x", "yt"), style: (stroke: blue)) plot.add(circle-data, axes: ("x", "yb"), style: (stroke: yellow)) }) -}),)) +}) /* Anchors */ #test-case({ @@ -198,16 +196,6 @@ mark: (start: ">", end: ">"), name: "amplitude") }) -/* Custom sample points */ -#test-case({ - plot.plot(size: (6, 4), y-min: -2, y-max: 2, - samples: 10, - { - plot.add(samples: 2, sample-at: (.99, 1.001, 1.99, 2.001, 2.99), domain: (0, 3), - x => calc.pow(-1, int(x))) - }) -}) - /* Format tick values */ #test-case({ plot.plot(size: (6, 4), @@ -217,15 +205,15 @@ y-tick-step: none, y-ticks: (-1, 0, 1), y-format: x => $y_(#x)$, - x2-tick-step: none, - x2-ticks: (-1, 0, 1), - x2-format: x => $x_(2,#x)$, - y2-tick-step: none, - y2-ticks: (-1, 0, 1), - y2-format: x => $y_(2,#x)$, + u-tick-step: none, + u-ticks: (-1, 0, 1), + u-format: x => $x_(2,#x)$, + v-tick-step: none, + v-ticks: (-1, 0, 1), + v-format: x => $y_(2,#x)$, { plot.add(samples: 2, domain: (-1, 1), x => -x, axes: ("x", "y")) - plot.add(samples: 2, domain: (-1, 1), x => x, axes: ("x2", "y2")) + plot.add(samples: 2, domain: (-1, 1), x => x, axes: ("u", "v")) }) }) @@ -256,13 +244,13 @@ tick: ( length: -.1, ), - left: ( + y: ( stroke: (paint: red), tick: ( stroke: auto, ) ), - bottom: ( + x: ( stroke: (paint: blue, thickness: 2pt), tick: ( stroke: auto,