diff --git a/src/axis.typ b/src/axis.typ index 3d22444..378b507 100644 --- a/src/axis.typ +++ b/src/axis.typ @@ -20,30 +20,33 @@ } /// Linear Axis Constructor -#let linear(name, min, max) = ( +#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: none, compute-ticks: ticks.compute-ticks.with("lin"), -) +) + options.named() /// Log Axis Constructor -#let logarithmic(name, min, max, base) = ( +#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: none, compute-ticks: ticks.compute-ticks.with("log"), -) +) + options.named() // Prepare axis #let prepare(ptx, ax) = { if ax.min == none { ax.min = ax.auto-domain.at(0) } if ax.max == none { ax.max = ax.auto-domain.at(1) } - if "compute-ticks" in ax { ax.computed-ticks = (ax.compute-ticks)(ax) } + 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 } diff --git a/src/plot.typ b/src/plot.typ index 5e3da4f..c1e5b98 100644 --- a/src/plot.typ +++ b/src/plot.typ @@ -39,9 +39,9 @@ /// - name (str): Axis name /// - min: (none, float): Minimum /// - max: (none, float): Maximum -#let lin-axis(name, min: none, max: none) = { +#let lin-axis(name, min: none, max: none, ..options) = { ((priority: -100, fn: (ptx) => { - ptx.axes.insert(name, axis.linear(name, min, max)) + ptx.axes.insert(name, axis.linear(name, min, max, ..options)) return ptx }),) } @@ -51,9 +51,9 @@ /// - min: (none, float): Minimum /// - max: (none, float): Maximum /// - base: (int): Log base -#let log-axis(name, min: none, max: none, base: 10) = { +#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)) + ptx.axes.insert(name, axis.logarithmic(name, min, max, base, ..options)) return ptx }),) } @@ -63,7 +63,9 @@ scientific: (ptx) => { lin-axis("x") lin-axis("y") - sub-plot.new("x", "y") + lin-axis("u") + lin-axis("v") + sub-plot.new("x", "y", "u", "v") }, school-book: (ptx) => { lin-axis("x") diff --git a/src/plot/util.typ b/src/plot/util.typ index 30830f2..de28264 100644 --- a/src/plot/util.typ +++ b/src/plot/util.typ @@ -204,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 @@ -214,7 +230,23 @@ /// - 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 + 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) } @@ -354,6 +386,7 @@ 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) diff --git a/src/spine.typ b/src/spine.typ index a88a92e..15e7ce6 100644 --- a/src/spine.typ +++ b/src/spine.typ @@ -3,6 +3,7 @@ #import "/src/ticks.typ" #import "/src/projection.typ" +#import "/src/axis.typ" /// Default axis style /// @@ -85,21 +86,19 @@ ), y: ( tick: ( - flip: true, label: ( anchor: "east", ), ), ), - x2: ( + u: ( tick: ( - flip: true, label: ( anchor: "south", ), ), ), - y2: ( + v: ( tick: ( label: ( anchor: "west", @@ -108,7 +107,6 @@ ), distal: ( tick: ( - flip: true, label: ( anchor: "east", ) @@ -160,73 +158,97 @@ #let _get-axis-style(ptx, style, name) = { return _prepare-style(ptx, if name in style { - cetz.util.merge-dictionary(style, style.at(name)) + cetz.util.merge-dictionary(style, style.at(name, default: (:))) } else { style }) } +/// +#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))) + } +} + /// #let cartesian-scientific(projections: none, name: none, 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 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") let y-style = _get-axis-style(ptx, style, "y") - let x2-style = _get-axis-style(ptx, style, "x2") - let y2-style = _get-axis-style(ptx, style, "y2") + let u-style = _get-axis-style(ptx, style, "u") + let v-style = _get-axis-style(ptx, style, "v") - let (south-west, south-east, north-west, north-east) = (proj.transform)( + let (x-low, x-high, y-low, y-high) = (xy-proj.transform)( (x.min, y.min), (x.max, y.min), - (x.min, y.max), (x.max, y.max), + (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 x-padding = x-style.padding - let y-padding = y-style.padding - - let x-low = vector.add(south-west, (-x-padding.at(0), -y-padding.at(0))) - let x-high = vector.add(south-east, (+x-padding.at(1), -y-padding.at(0))) - let y-low = vector.add(south-west, (-x-padding.at(0), -y-padding.at(0))) - let y-high = vector.add(north-west, (-x-padding.at(0), y-padding.at(1))) + let move-vec(v, direction, length) = { + vector.add(v, direction.enumerate().map(((i, v)) => v * length.at(i))) + } - let x2-low = vector.add(north-west, (-x-padding.at(0), y-padding.at(1))) - let x2-high = vector.add(north-east, (+x-padding.at(1), y-padding.at(1))) - let y2-low = vector.add(south-east, (x-padding.at(1), -y-padding.at(0))) - let y2-high = vector.add(north-east, (x-padding.at(1), y-padding.at(1))) + // 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 = x-low.at(1) - u-low.at(1) + let v-grid-length = y-low.at(0) - v-low.at(0) let axes = ( - (x, 0, south-west, south-east, x-low, x-high, x-style, false), - (y, 1, south-west, north-west, y-low, y-high, y-style, false), - (x, 0, north-west, north-east, x2-low, x2-high, x2-style, true), - (y, 1, south-east, north-east, y2-low, y2-high, y2-style, true), + (x, (0,+1), (0,x-grid-length), cartesian-axis-projection(x, x-low, x-high), x-style, false), + (y, (+1,0), (y-grid-length,0), cartesian-axis-projection(y, y-low, y-high), y-style, false), + (u, (0,-1), (0,u-grid-length), cartesian-axis-projection(u, u-low, u-high), u-style, not has-uv), + (v, (-1,0), (v-grid-length,0), cartesian-axis-projection(v, v-low, v-high), v-style, not has-uv), ) - for (ax, component, low, high, frame-low, frame-high, style, mirror) in axes { - draw.on-layer(style.axis-layer, { - draw.line(frame-low, frame-high, stroke: style.stroke, mark: style.mark) - }) - if "computed-ticks" in ax { - let low = low.enumerate().map(((i, v)) => { - if i == component { v } else { frame-low.at(i) } + draw.group(name: "spine", { + for (ax, dir, grid-dir, 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 high = high.enumerate().map(((i, v)) => { - if i == component { v } else { frame-low.at(i) } - }) - - if not mirror { - ticks.draw-cartesian-grid(low, high, component, ax, ax.computed-ticks, (0,0), (1,0), style) + if "computed-ticks" in ax { + if not mirror { + ticks.draw-cartesian-grid(proj, grid-dir, ax, ax.computed-ticks, style) + } + ticks.draw-cartesian(proj, dir, ax.computed-ticks, style, is-mirror: mirror) } - ticks.draw-cartesian(low, high, ax.computed-ticks, style, is-mirror: mirror) } - } + }) + + // TODO: Draw labels }, ) } @@ -247,10 +269,17 @@ 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, - vector.add(zero, (x.min, 0)), vector.add(zero, (x.max, 0)), - vector.add(zero, (0, y.min)), vector.add(zero, (0, y.max)), + 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 @@ -272,6 +301,7 @@ 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), @@ -279,8 +309,9 @@ stroke: x-style.stroke) }) if "computed-ticks" in x { - ticks.draw-cartesian-grid(min-x, max-x, 0, x, x.computed-ticks, min-y, max-y, x-style) - ticks.draw-cartesian(min-x, max-x, x.computed-ticks, x-style) + //ticks.draw-cartesian-grid(grid-proj, grid-dir, ax, ax.computed-ticks, style) + let tick-proj = cartesian-axis-projection(x, min-x, max-x) + ticks.draw-cartesian(tick-proj, (0,+1), x.computed-ticks, x-style) } draw.on-layer(y-style.axis-layer, { @@ -290,8 +321,9 @@ stroke: y-style.stroke) }) if "computed-ticks" in y { - ticks.draw-cartesian-grid(min-y, max-y, 1, y, y.computed-ticks, min-x, max-x, y-style) - ticks.draw-cartesian(min-y, max-y, y.computed-ticks, y-style) + //ticks.draw-cartesian-grid(min-y, max-y, 1, y, y.computed-ticks, min-x, max-x, y-style) + let tick-proj = cartesian-axis-projection(y, min-y, max-y) + ticks.draw-cartesian(tick-proj, (+1,0), y.computed-ticks, y-style) } } ) @@ -332,7 +364,7 @@ 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) + //ticks.draw-cartesian(r-start, r-end, distal.computed-ticks, distal-style) } if start == stop { diff --git a/src/ticks.typ b/src/ticks.typ index 8130192..9e3f380 100644 --- a/src/ticks.typ +++ b/src/ticks.typ @@ -45,77 +45,81 @@ return value } -// Compute list of linear ticks for axis -// -// - axis (axis): Axis -#let compute-linear-ticks(axis, add-zero: true) = { +#let clip-ticks(axis, ticks) = { 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 = axis.at("tick-limit", default: 100) - let minor-tick-limit = axis.at("minor-tick-limit", default: 1000) + let err = util.float-epsilon + /* + return ticks.filter(((value, ..)) => { + min - err <= value and value <= max + err + }) + */ + return ticks +} - 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 } +/// 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 s = 1 / ticks.step + let num-negative = int((0 - min) / step) + let num-positive = int((max - 0) / 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)) + return range(-num-negative, num-positive + 1).map(t => { + t * step + }) + } - 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 } + let major-limit = ax.at("tick-limit", default: 100) + let minor-limit = ax.at("minor-tick-limit", default: 1000) - if v >= 0 - ferr and v <= 1 + ferr { - l.push((v, format-tick-value(t / s, ticks), true)) - major-tick-values.push(v) - } - } - } + 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) - 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 + minor.map(value => { + (value, none, false) + }) + major.map(value => { + (value, format-tick-value(value, ax.ticks), true) + }) +} - 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)) +/// Compute list of logarithmic ticks +/// +/// - ax (axis): Axis +/// -> List of ticks +#let compute-logarithmic-ticks(ax) = { + let min = calc.log(calc.max(axis.min, util.float-epsilon), base: ax.base) + let max = calc.log(calc.max(axis.max, util.float-epsilon), base: ax.base) - 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 - } + let compute-list(min, max, step, limit) = { + let num-positive = int((max - 0) / step) - if v != none and v >= 0 and v <= 1 + ferr { - l.push((v, none, false)) - } - } - } + // TODO + return () } - return l + 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 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), @@ -141,20 +145,6 @@ 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 { @@ -205,11 +195,10 @@ (v, label) = t } - v = value-on-axis(axis, v) - if v != none and v >= 0 and v <= 1 { - l.push((v, label, true)) + if v != none { + return (v, label, true) } - }) + }).filter(v => v != none) } // Compute list of axis ticks @@ -219,12 +208,16 @@ // // - mode (str): "lin" or "log" // - axis (axis): Axis object -#let compute-ticks(mode, axis, add-zero: true) = { +#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(axis, n: 11) = { - let dt = calc.abs(axis.max - axis.min) + 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} @@ -254,22 +247,19 @@ } let ticks = if mode == "log" { - compute-logarithmic-ticks(axis, add-zero: add-zero) + compute-logarithmic-ticks(axis) } else { - compute-linear-ticks(axis, add-zero: add-zero) + compute-linear-ticks(axis) } ticks += fixed-ticks(axis) return ticks } // Place a list of tick marks and labels along a line -#let draw-cartesian(start, stop, ticks, style, is-mirror: false, show-zero: true) = { +#let draw-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 dir = vector.norm(vector.sub(stop, start)) - let norm = (-dir.at(1), dir.at(0), dir.at(2, default: 0)) - let def(v, d) = { return if v == none or v == auto {d} else {v} } @@ -279,12 +269,12 @@ show-label = not is-mirror } - for (distance, label, is-major) in ticks { + 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 = vector.lerp(start, stop, distance) + let pt = transform(value) if style.tick.flip { offset = -offset - length } @@ -312,33 +302,20 @@ // Draw grid lines for the ticks of an axis // -// - ptx (context): Plot context -// - start (vector): Axis start -// - stop (vector): Axis stop -// - component (int): Vector compontent to use as direction -// - axis (dictionary): The axis -// - ticks (array): The computed ticks -// - low (vector): Start position of a grid-line at tick 0 -// - high (vector): End position of a grid-line at tick 0 -// - style (style): Style -#let draw-cartesian-grid(start, stop, component, axis, ticks, low, high, style) = { +#let draw-cartesian-grid(proj, offset, axis, ticks, style) = { let kind = _get-grid-mode(axis.grid) if kind > 0 { draw.on-layer(style.grid-layer, { - for (distance, label, is-major) in ticks { - let offset = vector.lerp(start, stop, distance) - - let start = low - start.at(component) = offset.at(component) - let end = high - end.at(component) = offset.at(component) + for (value, _, major) in ticks { + let start = proj(value) + let end = vector.add(start, offset) // Draw a minor line - if not is-major and kind >= 2 { + if not major and kind >= 2 { draw.line(start, end, stroke: style.grid.minor-stroke) } // Draw a major line - if is-major and (kind == 1 or kind == 3) { + if major and (kind == 1 or kind == 3) { draw.line(start, end, stroke: style.grid.stroke) } }