// Axis generation: vega-parser `axis.js` and `guides/axis-*.js` evaluated
// directly (the signals they reference are known once the view is sized),
// followed by the axis layout of vega-view-transforms.
///|
/// What the compiler decided for one axis.
pub(all) struct AxisPlan {
scale : RScale
/// domain values of a band / point scale, in order
domain : Array[@value.Value]
orient : String
title : String?
/// explicit axis properties (`grid`, `labelAngle`, `tickCount`, ...)
props : Map[String, @value.Value]
/// the scale whose range the grid lines span
grid_scale : RScale?
/// number of ticks wanted (already resolved, e.g. `ceil(height/40)`)
tick_count : Double
}
///|
pub(all) struct Axis {
mark : @scene.Mark
orient : String
has_grid : Bool
has_ticks : Bool
has_labels : Bool
has_domain : Bool
has_title : Bool
/// label overlap removal: "parity" or "greedy"
overlap : String?
overlap_separation : Double
}
///|
fn is_x_orient(orient : String) -> Bool {
orient == "top" || orient == "bottom"
}
// ---- configuration defaults (vega-parser/src/config.js) ----
///|
fn axis_default(name : String, band : Bool) -> @value.Value {
match name {
"minExtent" => Num(0.0)
"maxExtent" => Num(200.0)
"bandPosition" => Num(0.5)
"domain" => Bool(true)
"domainWidth" => Num(1.0)
"domainColor" => Str("#888")
"grid" => Bool(false)
"gridWidth" => Num(1.0)
"gridColor" => Str("#ddd")
"labels" => Bool(true)
"labelAngle" => Num(0.0)
"labelLimit" => Num(180.0)
"labelOffset" => Num(0.0)
"labelPadding" => Num(2.0)
"ticks" => Bool(true)
"tickColor" => Str("#888")
"tickOffset" => Num(if band { -0.5 } else { 0.0 })
"tickRound" => Bool(true)
"tickSize" => Num(5.0)
"tickWidth" => Num(1.0)
"titlePadding" => Num(4.0)
_ => Undef
}
}
///|
/// `value(spec[name], value(config[name], dflt))`
fn AxisPlan::lookup(self : AxisPlan, name : String) -> @value.Value {
match self.props.get(name) {
Some(Null) | Some(Undef) | None =>
axis_default(
name,
match self.scale {
Band(b) => !b.is_point
_ => false
},
)
Some(v) => v
}
}
///|
/// A property that may be a `{condition: {test, value}, value}` object,
/// evaluated against one tick (`datum.value`, `datum.label`, `datum.index`).
fn AxisPlan::tick_prop(
self : AxisPlan,
name : String,
t : Tick,
) -> @value.Value raise CompileError {
match self.lookup(name) {
Obj(m) if m.contains("condition") => {
let datum : Map[String, @value.Value] = Map([])
datum["value"] = t.value
datum["label"] = Str(t.label)
datum["index"] = Num(t.index)
let conds = match m.get("condition") {
Some(Arr(a)) => a
Some(o) => [o]
None => []
}
for c in conds {
match c {
Obj(cm) =>
match cm.get("test") {
Some(Str(src)) => {
let ast = @expr.parse_expr(src) catch {
@expr.ExprError(e) =>
raise CompileError(name + ".condition.test: " + e)
}
let r = @expr.eval_expr(ast, Obj(datum)) catch {
@expr.ExprError(e) =>
raise CompileError(name + ".condition.test: " + e)
}
if r.truthy() {
return match cm.get("value") {
Some(v) => v
None => Undef
}
}
}
_ =>
raise CompileError(
"axis." + name + ".condition.test 暂不支持",
)
}
_ => ()
}
}
match m.get("value") {
Some(v) => v
None => Undef
}
}
v => v
}
}
///|
fn AxisPlan::flag(self : AxisPlan, name : String) -> Bool {
self.lookup(name).truthy()
}
///|
fn AxisPlan::number(self : AxisPlan, name : String, dflt : Double) -> Double {
match self.lookup(name) {
Null | Undef => dflt
v => v.to_number()
}
}
///|
/// The explicit value of an optional string property.
fn AxisPlan::opt_str(self : AxisPlan, name : String) -> String? {
match self.lookup(name) {
Null | Undef => None
v => Some(v.to_js_string())
}
}
// ---- ticks ----
///|
priv struct Tick {
value : @value.Value
label : String
index : Double
}
///|
fn lerp_range(r : (Double, Double), t : Double) -> Double {
let (lo, hi) = r
if t == 0.0 {
lo
} else if t == 1.0 {
hi
} else {
lo + t * (hi - lo)
}
}
///|
/// vega `flush(range, value, threshold, left, right, center)`: -1 for left,
/// 1 for right, 0 for center.
fn flush(range : (Double, Double), value : Double, t : Double?) -> Int32 {
match t {
None => 0
Some(t) => {
let mut a = range.0
let mut b = range.1
if b < a {
let l = a
a = b
b = l
}
let l = (value - a).abs()
let r = (b - value).abs()
if l < r && l <= t {
-1
} else if r <= t {
1
} else {
0
}
}
}
}
///|
fn AxisPlan::effective_count(
self : AxisPlan,
d0 : Double,
d1 : Double,
) -> Double {
match self.props.get("tickMinStep") {
Some(Num(step)) => {
let q = (d1 - d0) / step
let q = if q.is_nan() || q == 0.0 { 1.0 } else { q }
min(self.tick_count, q.floor() + 1.0)
}
_ => self.tick_count
}
}
///|
fn AxisPlan::ticks(self : AxisPlan) -> Array[Tick] raise CompileError {
let spec = self.opt_str("format")
match self.scale {
Band(_) => {
let out : Array[Tick] = []
let as_time = self.opt_str("formatType") == Some("time")
for v in self.domain {
let label = match spec {
None => v.to_js_string()
Some(f) =>
if as_time {
@time.utc_format(f)(v.to_number())
} else {
(@format.format(f) catch {
_ => raise CompileError("axis.format 无效: " + f)
})(v.to_number())
}
}
out.push({ value: v, label, index: 0.0, })
}
out
}
Binned(c, bins) => {
let d0 = c.domain[0]
let d1 = c.domain[c.domain.length() - 1]
let count = max(self.tick_count, bins.length().to_double())
let fmt = @format.format_span(d0, d1, count, specifier?=spec) catch {
_ => raise CompileError("axis.format 无效")
}
valid_ticks(c, bins, count).map(fn(x) {
{ value: Num(x), label: fmt(x), index: 0.0, }
})
}
Time(c) => {
let d0 = c.domain[0]
let d1 = c.domain[c.domain.length() - 1]
let fmt = match spec {
Some(f) => @time.utc_format(f)
None => @time.utc_multi_format()
}
@time.utc_ticks(d0, d1, self.effective_count(d0, d1)).map(fn(x) {
{ value: Num(x), label: fmt(x), index: 0.0, }
})
}
Cont(c) =>
match c.kind {
Log(base) => {
let count = self.tick_count
let ticks = c.ticks(count~)
let varfmt = @format.format_float(
match spec {
Some(f) => f
None => ""
},
) catch {
_ => raise CompileError("axis.format 无效")
}
let logb = @util.js_log(base)
let k = max(1.0, base * count / ticks.length().to_double())
let keep = fn(d : Double) {
let pw = if base == 10.0 {
@util.js_pow10(round(@util.js_log(d) / logb))
} else {
@math.pow(base, round(@util.js_log(d) / logb))
}
let mut i = d / pw
if i * base < base - 0.5 {
i = i * base
}
i <= k
}
ticks.map(fn(x) {
{
value: Num(x),
label: if keep(x) {
varfmt(x)
} else {
""
},
index: 0.0,
}
})
}
_ => {
let d0 = c.domain[0]
let d1 = c.domain[c.domain.length() - 1]
let fmt = @format.format_span(
d0,
d1,
self.tick_count,
specifier?=spec,
) catch {
_ => raise CompileError("axis.format 无效")
}
c
.ticks(count=self.tick_count)
.map(fn(x) { { value: Num(x), label: fmt(x), index: 0.0, } })
}
}
}
}
// ---- construction ----
///|
fn text_item(
props : Array[(String, @value.Value)],
style : String,
) -> @scene.Item {
// the `guide-label` / `guide-title` styles of the default config
let base = [("fill", str("#000")), ("font", str("sans-serif"))]
let size = match style {
"guide-title" => 11.0
"group-title" => 13.0
_ => 10.0
}
let all = base + [("fontSize", num(size))]
let it = mk_item(all)
if style == "guide-title" || style == "group-title" {
it.set_str("fontWeight", "bold")
}
for p in props {
it.set(p.0, p.1)
}
it
}
///|
fn guide_mark(
marktype : @scene.MarkType,
role : String,
items : Array[@scene.Item],
) -> @scene.Mark {
@scene.Mark::new(marktype, role~, interactive=false, items~)
}
///|
fn build_axis(
plan : AxisPlan,
width : Double,
height : Double,
) -> Axis raise CompileError {
let orient = plan.orient
let horizontal = is_x_orient(orient)
let has_grid = plan.flag("grid")
let has_ticks = plan.flag("ticks")
let has_labels = plan.flag("labels")
let has_domain = plan.flag("domain")
let has_title = plan.title is Some(_)
let raw_ticks = plan.ticks()
let n_ticks = raw_ticks.length().to_double()
let ticks = raw_ticks.mapi(fn(i, t) {
{
..t,
index: i.to_double() /
(if n_ticks - 1.0 == 0.0 { 1.0 } else { n_ticks - 1.0 }),
}
})
let range = plan.scale.range_ends()
let round_pos = plan.flag("tickRound")
let tick_offset = plan.number("tickOffset", 0.0)
let band_pos = plan.number("bandPosition", 0.5)
let bw = plan.scale.bandwidth()
let children : Array[@scene.Mark] = []
// position of a tick along the axis: scale(value) [+ bandwidth * band] + offset
let pos = fn(
v : @value.Value,
band : Double,
offset : Double,
round_it : Bool,
) {
let p = match plan.scale.map(v) {
Some(x) => x
None => @double.not_a_number
}
let r = p + bw * band + offset
if round_it {
round(r)
} else {
r
}
}
if has_grid {
let sign = if orient == "left" || orient == "top" { 1.0 } else { -1.0 }
let (start, end) = match plan.grid_scale {
Some(gs) => {
let gr = gs.range_ends()
(lerp_range(gr, 0.0) * sign + 0.0, lerp_range(gr, 1.0) * sign + 0.0)
}
None => (0.0, (if horizontal { height } else { width }) * sign + 0.0)
}
let items = ticks.map(fn(t) raise CompileError {
let p = pos(t.value, band_pos, tick_offset, round_pos)
let it = mk_item([
("opacity", num(1.0)),
("stroke", plan.tick_prop("gridColor", t)),
("strokeWidth", plan.tick_prop("gridWidth", t)),
])
if horizontal {
it.set_num("x", p)
it.set_num("y", start)
it.set_num("y2", end)
} else {
it.set_num("y", p)
it.set_num("x", start)
it.set_num("x2", end)
}
it
})
children.push(guide_mark(Rule, "axis-grid", items))
}
let size = plan.number("tickSize", 5.0)
let tick_sign = if orient == "left" || orient == "top" { -1.0 } else { 1.0 }
if has_ticks {
let items = ticks.map(fn(t) raise CompileError {
let p = pos(t.value, band_pos, tick_offset, round_pos)
let it = mk_item([
("opacity", num(1.0)),
("stroke", plan.tick_prop("tickColor", t)),
("strokeWidth", plan.tick_prop("tickWidth", t)),
])
if horizontal {
it.set_num("y", 0.0)
it.set_num("y2", size * tick_sign)
it.set_num("x", p)
} else {
it.set_num("x", 0.0)
it.set_num("x2", size * tick_sign)
it.set_num("y", p)
}
it
})
children.push(guide_mark(Rule, "axis-tick", items))
}
if has_labels {
let label_size = if has_ticks { size } else { 0.0 }
let pad = plan.number("labelPadding", 0.0)
let edge = label_size * tick_sign + pad * tick_sign
let label_offset = plan.number("labelOffset", 0.0)
let flush_t : Double? = match plan.lookup("labelFlush") {
Undef | Null | Bool(false) => None
v => Some(v.to_number())
}
let items = ticks.map(fn(t) raise CompileError {
let p = pos(t.value, 0.5, tick_offset + label_offset, false)
let it = text_item(
[
("opacity", num(1.0)),
("angle", plan.lookup("labelAngle")),
("limit", plan.lookup("labelLimit")),
("text", str(t.label)),
],
"guide-label",
)
let sv = match plan.scale.map(t.value) {
Some(v) => v
None => @double.not_a_number
}
let flushed = flush(range, sv, flush_t)
if horizontal {
it.set_num("x", p)
it.set_num("y", edge)
it.set_str(
"align",
match flushed {
-1 => "left"
1 => "right"
_ => "center"
},
)
it.set_str("baseline", if orient == "top" { "bottom" } else { "top" })
} else {
it.set_num("y", p)
it.set_num("x", edge)
it.set_str("align", if orient == "right" { "left" } else { "right" })
it.set_str(
"baseline",
match flushed {
-1 => "top"
1 => "bottom"
_ => "middle"
},
)
}
match plan.opt_str("labelAlign") {
Some(a) => it.set_str("align", a)
None => ()
}
match plan.opt_str("labelBaseline") {
Some(b) => it.set_str("baseline", b)
None => ()
}
match plan.tick_prop("labelColor", t) {
Undef | Null => ()
c => it.set("fill", c)
}
match plan.tick_prop("labelFontSize", t) {
Undef | Null => ()
c => it.set("fontSize", c)
}
it
})
children.push(guide_mark(Text, "axis-label", items))
}
if has_domain {
let it = mk_item([
("opacity", num(1.0)),
("stroke", plan.lookup("domainColor")),
("strokeWidth", plan.lookup("domainWidth")),
])
if horizontal {
it.set_num("x", lerp_range(range, 0.0))
it.set_num("x2", lerp_range(range, 1.0))
it.set_num("y", 0.0)
} else {
it.set_num("y", lerp_range(range, 0.0))
it.set_num("y2", lerp_range(range, 1.0))
it.set_num("x", 0.0)
}
children.push(guide_mark(Rule, "axis-domain", [it]))
}
match plan.title {
Some(title) => {
let t_sign = if orient == "left" || orient == "top" { -1.0 } else { 1.0 }
let it = text_item(
[
("opacity", num(1.0)),
("anchor", Null),
("align", str("center")),
("auto", boolean(true)),
("text", str(title)),
],
"guide-title",
)
let mid = lerp_range(range, 0.5)
if horizontal {
it.set_num("x", mid)
it.set_num("angle", 0.0)
it.set_str("baseline", if orient == "top" { "bottom" } else { "top" })
} else {
it.set_num("y", mid)
it.set_num("angle", t_sign * 90.0)
it.set_str("baseline", "bottom")
}
children.push(guide_mark(Text, "axis-title", [it]))
}
None => ()
}
let item = mk_item([
("orient", str(orient)),
("offset", num(plan.number("offset", 0.0))),
("position", num(plan.number("position", 0.0))),
("titlePadding", num(plan.number("titlePadding", 0.0))),
("minExtent", num(plan.number("minExtent", 0.0))),
("maxExtent", num(plan.number("maxExtent", 0.0))),
("range", num((range.1 - range.0).abs())),
])
for c in children {
item.children.push(c)
}
match plan.props.get("format") {
Some(Str(f)) => {
item.set_str("format", f)
match plan.props.get("formatType") {
Some(Str(t)) => item.set_str("formatType", t)
_ => ()
}
}
_ => ()
}
let mark = @scene.Mark::new(
Group,
role="axis",
interactive=false,
aria=plan.flag("aria") || !plan.props.contains("aria"),
items=[item],
)
let overlap = match plan.lookup("labelOverlap") {
Bool(true) => Some("parity")
Str(m) => Some(m)
_ => None
}
{
mark,
orient,
has_grid,
has_ticks,
has_labels,
has_domain,
has_title,
overlap,
overlap_separation: plan.number("labelSeparation", 0.0),
}
}
// ---- layout ----
///|
/// vega-view-transforms `axisLayout`: position the axis group and its title,
/// returning the bounds the axis occupies in the view.
fn axis_layout(ax : Axis, width : Double, height : Double) -> @scene.Bounds {
let item = ax.mark.items[0]
let delta = item.num_or("translate", 0.5)
let range = item.num0("range")
let offset = item.num0("offset")
let position = item.num0("position")
let min_extent = item.num0("minExtent")
let max_extent = item.num0("maxExtent")
let title_padding = item.num0("titlePadding")
let bounds = item.bounds
// child indices: grid, ticks, labels, domain, title (those present)
let mut index = 0
let grid = if ax.has_grid {
index += 1
Some(item.children[index - 1])
} else {
None
}
let ticks = if ax.has_ticks {
index += 1
Some(item.children[index - 1])
} else {
None
}
let labels = if ax.has_labels {
index += 1
Some(item.children[index - 1])
} else {
None
}
let title_mark = if ax.has_title {
Some(item.children[index + (if ax.has_domain { 1 } else { 0 })])
} else {
None
}
let title = match title_mark {
Some(m) => Some(m.items[0])
None => None
}
ignore(bounds.clear())
// vega unions the tick and label marks (indices 0 and 1 among grid, ticks)
let _ = grid
match ticks {
Some(m) => ignore(bounds.union(m.bounds))
None => ()
}
match labels {
Some(m) => ignore(bounds.union(m.bounds))
None => ()
}
let mut x = 0.0
let mut y = 0.0
let dl = match title {
Some(t) => @scene.multi_line_offset(t)
None => 0.0
}
match ax.orient {
"top" => {
x = position
y = -offset
let s = max(min_extent, min(max_extent, -bounds.y1))
ignore(bounds.add(0.0, -s).add(range, 0.0))
axis_title_layout(
title, title_mark, s, title_padding, dl, false, -1.0, bounds,
)
}
"left" => {
x = -offset
y = position
let s = max(min_extent, min(max_extent, -bounds.x1))
ignore(bounds.add(-s, 0.0).add(0.0, range))
axis_title_layout(
title, title_mark, s, title_padding, dl, true, -1.0, bounds,
)
}
"right" => {
x = width + offset
y = position
let s = max(min_extent, min(max_extent, bounds.x2))
ignore(bounds.add(0.0, 0.0).add(s, range))
axis_title_layout(
title, title_mark, s, title_padding, dl, true, 1.0, bounds,
)
}
_ => {
x = position
y = height + offset
let s = max(min_extent, min(max_extent, bounds.y2))
ignore(bounds.add(0.0, 0.0).add(range, s))
axis_title_layout(
title, title_mark, s, title_padding, 0.0, false, 1.0, bounds,
)
}
}
ignore(@scene.bound_stroke(bounds.translate(x, y), item))
item.set_num("x", x + delta)
item.set_num("y", y + delta)
ax.mark.bounds.clear().union(bounds)
}
///|
fn axis_title_layout(
title : @scene.Item?,
title_mark : @scene.Mark?,
offset : Double,
pad : Double,
dl : Double,
is_y : Bool,
sign : Double,
bounds : @scene.Bounds,
) -> Unit {
match (title, title_mark) {
(Some(t), Some(m)) => {
let b = t.bounds
if t.truthy("auto") {
let v = sign * (offset + dl + pad)
let mut dx = 0.0
let mut dy = 0.0
if is_y {
dx = t.num0("x") - v
t.set_num("x", v)
} else {
dy = t.num0("y") - v
t.set_num("y", v)
}
ignore(m.bounds.clear().union(b.translate(-dx, -dy)))
}
ignore(bounds.union(b))
}
_ => ()
}
}
// ---- label overlap (vega-view-transforms `Overlap`) ----
///|
fn bounds_intersect(a : @scene.Bounds, b : @scene.Bounds, sep : Double) -> Bool {
sep > max(max(b.x1 - a.x2, a.x1 - b.x2), max(b.y1 - a.y2, a.y1 - b.y2))
}
///|
fn has_overlap(items : Array[@scene.Item], pad : Double) -> Bool {
for i in 1.. Unit {
let source = mark.items.filter(fn(it) {
it.bounds.width() > 1.0 && it.bounds.height() > 1.0
})
if source.length() == 0 {
return
}
for it in source {
it.set_num("opacity", 1.0)
}
let mut items = source
if items.length() >= 3 && has_overlap(items, sep) {
while true {
if how == "greedy" {
let kept : Array[@scene.Item] = []
let mut a = items[0]
for i in 0..= 3 && has_overlap(items, sep)) {
break
}
}
let last = source[source.length() - 1]
if items.length() < 3 && !last.truthy("opacity") {
if items.length() > 1 {
items[items.length() - 1].set_num("opacity", 0.0)
}
last.set_num("opacity", 1.0)
}
}
ignore(mark.bounds.clear())
for it in source {
if it.truthy("opacity") {
ignore(mark.bounds.union(it.bounds))
}
}
}
///|
/// Compute the bounds of an axis group: children first, labels de-overlapped,
/// then the group item itself.
fn bound_axis(ax : Axis) -> Unit raise CompileError {
let item = ax.mark.items[0]
try {
for child in item.children {
@scene.bound_mark(child)
if child.role == Some("axis-label") {
match ax.overlap {
Some(m) => remove_overlap(child, m, ax.overlap_separation)
None => ()
}
}
}
} catch {
@scene.SceneError(m) => raise CompileError(m)
}
ignore(ax.mark.bounds.clear())
ignore(item.bounds.clear())
ignore(@scene.bound_group(item.bounds, item))
ignore(ax.mark.bounds.union(item.bounds))
}
///|
/// vega-scale `validTicks`: keep candidate ticks inside the range, in range
/// order, and thin them out to at most `count`.
fn valid_ticks(
c : @scale.Continuous,
candidates : Array[Double],
count : Double,
) -> Array[Double] {
let r0 = c.range[0]
let r1 = c.range[c.range.length() - 1]
let reversed = r0 > r1
let lo = (if reversed { r1 } else { r0 }).floor()
let hi = (if reversed { r0 } else { r1 }).ceil()
let pairs : Array[(Double, Double)] = []
for v in candidates {
match c.map(v) {
Some(s) => if lo <= s && s <= hi { pairs.push((v, s)) }
None => ()
}
}
let sorted = @transform.stable_sort(pairs, fn(a, b) {
let d = if reversed { b.1 - a.1 } else { a.1 - b.1 }
if d < 0.0 {
-1
} else if d > 0.0 {
1
} else {
0
}
})
let mut ticks = sorted.map(fn(p) { p.0 })
if count > 0.0 && ticks.length() > 1 {
let endpoints = [ticks[0], ticks[ticks.length() - 1]]
while ticks.length().to_double() > count && ticks.length() >= 3 {
let kept : Array[Double] = []
for i in 0..