// Compilation of a single-view specification with x / y position channels.

///|
/// A position channel with everything resolved from its definition.
priv struct Pos {
  def : @spec.ChannelDef
  typ : String
  /// the data field this channel reads after the data pipeline
  field : String
  title : String
  discrete : Bool
  temporal : Bool
  time_unit : String?
  aggregate : String?
}

///|
fn derived_field(def : @spec.ChannelDef) -> String raise CompileError {
  match (def.aggregate, def.field) {
    (Some("count"), _) => "__count"
    (Some(op), Some(f)) => op + "_" + f
    (None, Some(f)) =>
      match def.bin {
        Some(m) => bin_name(m) + "_" + f
        None =>
          match def.time_unit {
            Some(u) => u + "_" + f
            None => f
          }
      }
    _ => raise CompileError("channel 缺少 field")
  }
}

///|
/// vega-lite's `binToString` for the supported parameters.
fn bin_name(m : Map[String, @value.Value]) -> String {
  match (m.get("maxbins"), m.get("step")) {
    (Some(Num(n)), _) =>
      "bin_maxbins_" + underscore(@util.js_number_to_string(n))
    (_, Some(Num(n))) => "bin_step_" + underscore(@util.js_number_to_string(n))
    _ => "bin_maxbins_10"
  }
}

///|
fn underscore(s : String) -> String {
  s.replace_all(old=".", new="_").replace_all(old="-", new="_")
}

///|
/// `titleCase`: upper-case the first character.
fn capitalize(s : String) -> String {
  if s.length() == 0 {
    s
  } else {
    s[0:1].to_upper().to_owned() + s[1:].to_owned()
  }
}

///|
fn default_title(def : @spec.ChannelDef) -> String raise CompileError {
  match (def.aggregate, def.field) {
    (Some("count"), _) => "Count of Records"
    (Some(op), Some(f)) => capitalize(op) + " of " + f
    (None, Some(f)) =>
      if def.bin is Some(_) {
        f + " (binned)"
      } else {
        match def.time_unit {
          Some(u) => f + " (" + unit_parts(u).join("-") + ")"
          None => f
        }
      }
    _ => raise CompileError("channel 缺少 field")
  }
}

///|
fn resolve_pos(chan : String, def : @spec.ChannelDef) -> Pos raise CompileError {
  let typ = match def.typ {
    Some(t) => t
    None =>
      if def.datum is Some(Num(_)) {
        "quantitative"
      } else if def.aggregate is Some(_) {
        "quantitative"
      } else if def.time_unit is Some(_) {
        "temporal"
      } else if def.bin is Some(_) {
        "quantitative"
      } else {
        match def.scale {
          Some(m) =>
            match m.get("type") {
              Some(Str("log"))
              | Some(Str("linear"))
              | Some(Str("sqrt"))
              | Some(Str("pow")) => "quantitative"
              Some(Str("time")) | Some(Str("utc")) => "temporal"
              _ => "nominal"
            }
          None => "nominal"
        }
      }
  }
  // a datum has no field to name an axis after; it is stored under a
  // synthetic field of every row so that scales and marks treat it as data
  let is_datum = def.datum is Some(_)
  let title = match def.title {
    Some(Some(t)) => t
    _ => if is_datum { "" } else { default_title(def) }
  }
  {
    def,
    typ,
    field: if is_datum {
      "__datum_" + chan
    } else {
      derived_field(def)
    },
    title,
    discrete: typ == "nominal" || typ == "ordinal",
    temporal: typ == "temporal",
    time_unit: def.time_unit,
    aggregate: def.aggregate,
  }
}

///|
/// `{step: n}` sizes, or a plain number.
fn step_of(v : @value.Value?) -> Double? {
  match v {
    Some(Obj(m)) =>
      match m.get("step") {
        Some(Num(x)) => Some(x)
        _ => None
      }
    _ => None
  }
}

///|
fn mark_color(spec : @spec.Spec) -> String {
  match spec.mark.props.get("color") {
    Some(Str(c)) => c
    _ => "#4c78a8"
  }
}

///|
fn domain_extent_multi(
  srcs : Array[(Array[Map[String, @value.Value]], Array[String])],
  zero : Bool,
) -> (Double, Double)? {
  let mut lo = @double.infinity
  let mut hi = @double.neg_infinity
  let mut nfields = 0
  for src in srcs {
    let (rows, fields) = src
    nfields += fields.length()
    for f in fields {
      match @transform.extent(rows, f) {
        Some((a, b)) => {
          lo = min(lo, a)
          hi = max(hi, b)
        }
        None => ()
      }
    }
  }
  if lo > hi {
    // no data: one field leaves the domain undefined (NaN); several fields
    // give an inverted infinite extent that `zero` collapses to [0, 0]
    return if nfields > 1 {
      if zero {
        Some((0.0, 0.0))
      } else {
        Some((@double.infinity, @double.neg_infinity))
      }
    } else {
      Some((@double.not_a_number, @double.not_a_number))
    }
  }
  if zero {
    lo = min(lo, 0.0)
    hi = max(hi, 0.0)
  }
  Some((lo, hi))
}

///|
fn domain_extent(
  rows : Array[Map[String, @value.Value]],
  fields : Array[String],
  zero : Bool,
) -> (Double, Double)? {
  domain_extent_multi([(rows, fields)], zero)
}

///|
/// Distinct values of several fields of several sources, in ascending order.
fn sorted_unique_multi(
  srcs : Array[(Array[Map[String, @value.Value]], Array[String])],
) -> Array[@value.Value] {
  let seen : Map[String, Bool] = Map([])
  let out : Array[@value.Value] = []
  for src in srcs {
    let (rows, fields) = src
    for f in fields {
      for v in first_appearance(rows, f) {
        let k = @scale.value_key(v)
        if !seen.contains(k) {
          seen[k] = true
          out.push(v)
        }
      }
    }
  }
  @transform.stable_sort(out, fn(a, b) { @value.compare_values(a, b) })
}

///|
/// The scale of a position channel; `size` is the view size along it (None
/// for a discrete scale whose size follows from its step).
fn pos_scale(
  p : Pos,
  srcs : Array[(Array[Map[String, @value.Value]], Array[String])],
  mark : String,
  range_start : Double,
  range_stop : Double,
  bins : (Double, Double, Double)?,
  zero_default : Bool,
  order : Array[@value.Value]?,
) -> (RScale, Array[@value.Value]) {
  if p.def.bin is Some(_) && bins is Some((start, stop, step)) {
    let c = @scale.Continuous::new(Linear)
    c.domain = [start, stop]
    c.range = [range_start, range_stop]
    let seq : Array[Double] = []
    let n = ((stop + step / 2.0 - start) / step).ceil()
    let mut i = 0.0
    while i < n {
      seq.push(start + i * step)
      i += 1.0
    }
    (Binned(c, seq), [])
  } else if p.discrete {
    let domain = match order {
      Some(o) => o
      None => sorted_unique_multi(srcs)
    }
    let band = if mark == "bar" ||
      mark == "rect" ||
      mark == "tick" ||
      mark == "rule" {
      let b = @scale.Band::band()
      let inner = if mark == "bar" {
        0.1
      } else if mark == "tick" {
        0.25
      } else {
        0.0
      }
      b.set_padding_inner(inner)
      b.set_padding_outer(inner / 2.0)
      b
    } else {
      let b = @scale.Band::point()
      b.set_padding(0.5)
      b
    }
    band.set_domain(domain)
    band.set_range(range_start, range_stop)
    (Band(band), domain)
  } else if p.temporal {
    let c = @scale.Continuous::new(Linear)
    match domain_extent_multi(srcs, false) {
      Some((lo, hi)) => c.domain = [lo, hi]
      None => ()
    }
    c.range = [range_start, range_stop]
    (Time(c), [])
  } else {
    let sprops : Map[String, @value.Value] = match p.def.scale {
      Some(m) => m
      None => Map([])
    }
    let stype = match sprops.get("type") {
      Some(Str(t)) => t
      _ => "linear"
    }
    let kind = match stype {
      "log" =>
        @scale.ScaleKind::Log(
          match sprops.get("base") {
            Some(Num(b)) => b
            _ => 10.0
          },
        )
      "sqrt" => @scale.ScaleKind::sqrt()
      "pow" =>
        @scale.ScaleKind::Pow(
          match sprops.get("exponent") {
            Some(Num(e)) => e
            _ => 1.0
          },
        )
      _ => @scale.ScaleKind::Linear
    }
    let c = @scale.Continuous::new(kind)
    let user_domain = match sprops.get("domain") {
      Some(Arr(a)) if a.length() >= 2 => Some(a.map(fn(v) { v.to_number() }))
      _ => None
    }
    let zero = match sprops.get("zero") {
      Some(Bool(z)) => z
      _ =>
        if user_domain is Some(_) || kind is Log(_) {
          false
        } else {
          zero_default
        }
    }
    match user_domain {
      Some(d) => c.domain = d
      None =>
        match domain_extent_multi(srcs, zero) {
          Some((lo, hi)) => c.domain = [lo, hi]
          None => ()
        }
    }
    match sprops.get("domainMin") {
      Some(Num(v)) => c.domain[0] = v
      _ => ()
    }
    match sprops.get("domainMax") {
      Some(Num(v)) => c.domain[c.domain.length() - 1] = v
      _ => ()
    }
    c.range = [range_start, range_stop]
    match sprops.get("range") {
      Some(Arr([Num(a), Num(b)])) => c.range = [a, b]
      _ => ()
    }
    match sprops.get("reverse") {
      Some(Bool(true)) => c.range = [c.range[1], c.range[0]]
      _ => ()
    }
    match sprops.get("clamp") {
      Some(Bool(true)) => c.clamp = true
      _ => ()
    }
    let nice = match sprops.get("nice") {
      Some(Bool(n)) => n
      Some(Num(_)) => true
      _ =>
        user_domain is None &&
        sprops.get("domainMin") is None &&
        sprops.get("domainMax") is None
    }
    if nice {
      c.nice()
    }
    (Cont(c), [])
  }
}

///|
priv struct UnitModel {
  spec : @spec.Spec
  mark : String
  px : Pos?
  py : Pos?
  px2 : Pos?
  py2 : Pos?
  pcolor : Pos?
  psize : Pos?
  popacity : Pos?
  pshape : Pos?
  ptext : Pos?
  /// the angle of an arc (the only position channel of an arc)
  ptheta : Pos?
  color_value : String?
  size_value : Double?
  opacity_value : Double?
  shape_value : String?
  bin_info : Map[String, (Double, Double, Double)]
  orient : String
  vertical : Bool
  config_zero : Bool
  stack_chan : String?
  stack_offset : String
  valid_fields : Array[String]
  data : Array[Map[String, @value.Value]]
  xfields : Array[String]
  yfields : Array[String]
  x_order : Array[@value.Value]?
  y_order : Array[@value.Value]?
}

///|
priv struct UnitScales {
  xs : (RScale, Array[@value.Value])?
  ys : (RScale, Array[@value.Value])?
  base_color : String
  color_scale : @scale.Ordinal
  color_domain : Array[@value.Value]
  color_seq : @scale.Sequential?
  size_scale : @scale.Continuous?
  opacity_scale : @scale.Continuous?
  shape_scale : @scale.Ordinal
  shape_domain : Array[@value.Value]
  /// angle (radians) of an arc: linear over the stacked extent
  theta_scale : @scale.Continuous?
}

///|
/// Discrete colour range: nominal fields use the categorical scheme, ordinal
/// fields sample the sequential `blues` scheme once per domain value (vega's
/// `configureScheme` for an interpolating scheme on an ordinal scale).
fn set_color_range(
  scale : @scale.Ordinal,
  domain : Array[@value.Value],
  ordinal : Bool,
) -> Unit {
  scale.set_domain(domain)
  if ordinal {
    match @scale.scheme_interpolator("blues") {
      Some(f) =>
        scale.set_range(
          @scale.quantize_interpolator(f, domain.length()).map(fn(c) { Str(c) }),
        )
      None => ()
    }
    return
  }
  match @scale.scheme("tableau10") {
    Some(Discrete(cs)) => scale.set_range(cs.map(fn(c) { Str(c) }))
    _ => ()
  }
}

///|
/// Run the data pipeline of a unit specification and resolve its channels.
fn build_unit(
  spec : @spec.Spec,
  shared_bins~ : Map[String, @transform.BinParams] = Map([]),
) -> UnitModel raise CompileError {
  let source = match spec.data {
    Some(d) => d
    None => raise CompileError("data 缺失")
  }
  let mark = spec.mark.typ
  if mark != "bar" &&
    mark != "point" &&
    mark != "tick" &&
    mark != "line" &&
    mark != "area" &&
    mark != "rule" &&
    mark != "text" &&
    mark != "rect" &&
    mark != "circle" &&
    mark != "square" &&
    mark != "arc" {
    raise CompileError("mark " + mark + " 暂不支持")
  }
  let mut rows = source.map(fn(r) { r.copy() })
  // temporal fields are parsed to dates (vega `toDate`)
  for d in [find_channel(spec, "x"), find_channel(spec, "y")] {
    match d {
      Some({ field: Some(f), aggregate: None, typ, time_unit, .. }) =>
        if typ == Some("temporal") || time_unit is Some(_) {
          for r in rows {
            r[f] = @value.get_field(r, f).coerce_date()
          }
        }
      _ => ()
    }
  }
  // user transforms
  rows = apply_transforms(rows, spec.transform)
  for d in [find_channel(spec, "x"), find_channel(spec, "y")] {
    match d {
      Some({ field: Some(f), time_unit: Some(u), .. }) =>
        @transform.timeunit(
          rows,
          f,
          unit_parts(u),
          as0=u + "_" + f,
          as1=u + "_" + f + "_end",
        ) catch {
          @time.TimeError(m) => raise CompileError(m)
          @transform.TransformError(m) => raise CompileError(m)
          _ => raise CompileError("timeUnit 失败")
        }
      _ => ()
    }
  }
  // binned fields
  let bin_info : Map[String, (Double, Double, Double)] = Map([])
  for ch in ["x", "y"] {
    match find_channel(spec, ch) {
      Some({ field: Some(f), bin: Some(m), aggregate: None, .. } as d) => {
        let name = derived_field(d)
        let p = match shared_bins.get(ch) {
          Some(p) => p
          None => bin_params(rows, f, m, 10.0)
        }
        let bins = @transform.compute_bins(p)
        bin_info[ch] = (bins.start, bins.stop, bins.step)
        @transform.bin_rows(rows, f, p, name, name + "_end")
      }
      _ => ()
    }
  }
  let xdef = find_channel(spec, "x")
  let ydef = find_channel(spec, "y")
  let px = match xdef {
    Some(d) => Some(resolve_pos("x", d))
    None => None
  }
  let py = match ydef {
    Some(d) => Some(resolve_pos("y", d))
    None => None
  }
  let positions : Array[Pos] = []
  match px {
    Some(p) => positions.push(p)
    None => ()
  }
  match py {
    Some(p) => positions.push(p)
    None => ()
  }
  let pcolor : Pos? = match find_channel(spec, "color") {
    Some(d) if d.field is Some(_) || d.aggregate is Some("count") =>
      Some(resolve_pos("color", d))
    _ => None
  }
  let px2 : Pos? = match find_channel(spec, "x2") {
    Some({ field: Some(_), .. } as d) => Some(resolve_pos("x2", d))
    _ => None
  }
  let py2 : Pos? = match find_channel(spec, "y2") {
    Some({ field: Some(_), .. } as d) => Some(resolve_pos("y2", d))
    _ => None
  }
  let px = match (px, px2) {
    (Some(a), Some(b)) =>
      // an explicit title of the first channel stands for the whole range
      if a.def.title is Some(Some(_)) {
        Some(a)
      } else {
        Some({ ..a, title: merge_titles(a.title, b.title), })
      }
    _ => px
  }
  let py = match (py, py2) {
    (Some(a), Some(b)) =>
      // an explicit title of the first channel stands for the whole range
      if a.def.title is Some(Some(_)) {
        Some(a)
      } else {
        Some({ ..a, title: merge_titles(a.title, b.title), })
      }
    _ => py
  }
  let psize : Pos? = match find_channel(spec, "size") {
    Some(d) if d.field is Some(_) || d.aggregate is Some("count") =>
      Some(resolve_pos("size", d))
    _ => None
  }
  let popacity : Pos? = match find_channel(spec, "opacity") {
    Some(d) if d.field is Some(_) || d.aggregate is Some("count") =>
      Some(resolve_pos("opacity", d))
    _ => None
  }
  let pshape : Pos? = match find_channel(spec, "shape") {
    Some(d) if d.field is Some(_) || d.aggregate is Some("count") =>
      Some(resolve_pos("shape", d))
    _ => None
  }
  // for rules and lines a constant size is the stroke width; the encoding
  // wins over the mark property
  let size_value : Double? = match find_channel(spec, "size") {
    Some({ value: Some(Num(v)), .. }) => Some(v)
    Some(_) => None
    None =>
      if mark == "rule" || mark == "line" {
        match spec.mark.props.get("size") {
          Some(Num(v)) => Some(v)
          _ => None
        }
      } else {
        None
      }
  }
  let opacity_value : Double? = match find_channel(spec, "opacity") {
    Some({ value: Some(Num(v)), .. }) => Some(v)
    _ => None
  }
  let shape_value : String? = match find_channel(spec, "shape") {
    Some({ value: Some(Str(v)), .. }) => Some(v)
    _ => None
  }
  if mark != "point" && mark != "circle" && mark != "square" {
    for ch in ["size", "opacity", "shape"] {
      let constant_size = ch == "size" &&
        (mark == "rule" || mark == "line") &&
        (match find_channel(spec, ch) {
          Some({ field: None, value: Some(_), .. }) => true
          _ => false
        })
      if find_channel(spec, ch) is Some(_) &&
        !constant_size &&
        !(ch == "opacity" &&
        (
          mark == "bar" ||
          mark == "tick" ||
          mark == "area" ||
          mark == "rect" ||
          mark == "arc"
        )) {
        raise CompileError(
          "encoding." + ch + " 暂不支持用于 mark " + mark,
        )
      }
    }
  }
  let ptheta : Pos? = match find_channel(spec, "theta") {
    Some(d) => Some(resolve_pos("theta", d))
    None => None
  }
  let ptext : Pos? = match find_channel(spec, "text") {
    Some({ field: Some(_), .. } as d) => Some(resolve_pos("text", d))
    _ => None
  }
  let color_value : String? = match find_channel(spec, "color") {
    Some({ value: Some(Str(c)), .. }) => Some(c)
    _ => None
  }
  // encoding-level aggregates
  let measures : Array[@transform.Measure] = []
  let groupby : Array[String] = []
  let measured : Array[Pos] = positions.copy()
  for o in [px2, py2, ptext, psize, popacity, pshape, ptheta] {
    match o {
      Some(p) => measured.push(p)
      None => ()
    }
  }
  for p in measured {
    match p.aggregate {
      Some(op) =>
        measures.push({
          op,
          field: if op == "count" {
            None
          } else {
            p.def.field
          },
          out: p.field,
        })
      None => {
        groupby.push(p.field)
        if is_ranged(mark, p) || p.def.bin is Some(_) {
          groupby.push(p.field + "_end")
        }
      }
    }
  }
  match pcolor {
    Some(c) => groupby.push(c.field)
    None => ()
  }
  for o in [psize, popacity, pshape] {
    match o {
      Some(p) => if p.aggregate is None { groupby.push(p.field) }
      None => ()
    }
  }
  let has_aggregate = measures.length() > 0
  // a datum is a constant of every row under its synthetic field
  for p in positions {
    match p.def.datum {
      Some(v) =>
        for r in rows {
          r[p.field] = v
        }
      None => ()
    }
  }
  let rows_pre = rows
  if has_aggregate {
    rows = @transform.aggregate(rows, groupby, measures) catch {
      @transform.TransformError(m) => raise CompileError(m)
    }
  }
  // orientation of bars: continuous field along the other axis
  let x_cont = match px {
    Some(p) => !p.discrete
    None => false
  }
  let y_cont = match py {
    Some(p) => !p.discrete
    None => false
  }
  let orient = infer_orient(mark, px, py)
  let config_zero = match spec.config.get("scale") {
    Some(Obj(m)) =>
      match m.get("zero") {
        Some(Bool(z)) => z
        _ => true
      }
    _ => true
  }
  let vertical = orient != "horizontal"
  ignore(x_cont)
  ignore(y_cont)
  // stacking: bars with an unaggregated continuous field
  let stack_chan0 : String? = if (mark == "bar" || mark == "area") &&
    px2 is None &&
    py2 is None &&
    !(has_aggregate && pcolor is None) {
    match (px, py) {
      (Some(x), Some(y)) => {
        // vega-lite's `potentialStackedChannel`
        let xq = is_measure(px)
        let yq = is_measure(py)
        let requested = fn(p : Pos) -> Bool {
          match p.def.stack {
            Some("none") | None => false
            Some(_) => true
          }
        }
        if xq && yq {
          if requested(x) {
            Some("x")
          } else if requested(y) {
            Some("y")
          } else if (x.aggregate is Some(_)) != (y.aggregate is Some(_)) {
            if x.aggregate is Some(_) {
              Some("x")
            } else {
              Some("y")
            }
          } else if orient == "horizontal" {
            Some("x")
          } else {
            Some("y")
          }
        } else if xq {
          Some("x")
        } else if yq {
          Some("y")
        } else {
          None
        }
      }
      _ => None
    }
  } else {
    None
  }
  // an arc stacks its angle channel around the circle
  let stack_chan0 : String? = if mark == "arc" {
    Some("theta")
  } else {
    stack_chan0
  }
  let stack_req : String? = match stack_chan0 {
    Some("theta") => ptheta.unwrap().def.stack
    Some("y") => py.unwrap().def.stack
    Some("x") => px.unwrap().def.stack
    _ => None
  }
  let stack_chan = if stack_req == Some("none") { None } else { stack_chan0 }
  let stack_offset = match stack_req {
    Some(o) => o
    None => "zero"
  }
  let stacked_field = match stack_chan {
    Some("theta") => ptheta.unwrap().field
    Some("y") => py.unwrap().field
    Some("x") => px.unwrap().field
    _ => ""
  }
  match stack_chan {
    Some(c) => {
      // an arc stacks around one circle (no dimension) and in ascending order of its colour
      let dim = match c {
        "theta" => ""
        "y" => px.unwrap().field
        _ => py.unwrap().field
      }
      // stacked areas are imputed so every series has a point at every key
      match pcolor {
        Some(col) if mark == "area" =>
          @transform.impute(rows, stacked_field, [col.field], dim, 0.0)
        _ => ()
      }
      @transform.stack(
        rows,
        Some(stacked_field),
        if c == "theta" {
          []
        } else {
          [dim]
        },
        match pcolor {
          Some(c2) => Some({ fields: [c2.field], descending: [c != "theta"], })
          None => None
        },
        stack_offset,
        stacked_field + "_start",
        stacked_field + "_end",
      )
    }
    None => ()
  }
  // drop invalid values of continuous position fields
  let valid_fields : Array[String] = []
  for p in positions {
    if !p.discrete && p.aggregate != Some("count") {
      valid_fields.push(p.field)
    }
  }
  for o in [psize, popacity, pcolor, ptheta] {
    match o {
      Some(p) =>
        if p.typ == "quantitative" && p.aggregate != Some("count") {
          valid_fields.push(p.field)
        }
      None => ()
    }
  }
  let is_path = mark == "line" || mark == "area"
  let data = if is_path {
    rows
  } else {
    rows.filter(fn(r) {
      valid_fields.all(fn(f) { is_finite_valid(@value.get_field(r, f)) })
    })
  }
  // domain fields and sort orders of the position scales
  let xfields = match px {
    Some(p) =>
      if stack_chan == Some("x") {
        [p.field + "_start", p.field + "_end"]
      } else if is_ranged(mark, p) {
        [p.field, p.field + "_end"]
      } else {
        match px2 {
          Some(q) => [p.field, q.field]
          None => [p.field]
        }
      }
    None => []
  }
  let yfields = match py {
    Some(p) =>
      if stack_chan == Some("y") {
        [p.field + "_start", p.field + "_end"]
      } else if is_ranged(mark, p) {
        [p.field, p.field + "_end"]
      } else {
        match py2 {
          Some(q) => [p.field, q.field]
          None => [p.field]
        }
      }
    None => []
  }
  let x_order = match px {
    Some({ def: { sort: Some(sv), .. }, discrete: true, .. } as p) =>
      sort_domain(p, sv, rows_pre, data, px, py)
    _ => None
  }
  let y_order = match py {
    Some({ def: { sort: Some(sv), .. }, discrete: true, .. } as p) =>
      sort_domain(p, sv, rows_pre, data, px, py)
    _ => None
  }
  {
    spec,
    mark,
    px,
    py,
    px2,
    py2,
    pcolor,
    psize,
    popacity,
    pshape,
    ptext,
    color_value,
    size_value,
    opacity_value,
    shape_value,
    bin_info,
    orient,
    vertical,
    config_zero,
    ptheta,
    stack_chan,
    stack_offset,
    valid_fields,
    data,
    xfields,
    yfields,
    x_order,
    y_order,
  }
}

///|
/// The size of the plot area.
fn unit_size(u : UnitModel) -> (Double, Double) {
  let spec = u.spec
  let data = u.data
  if u.mark == "arc" {
    let side = fn(v : @value.Value?) -> Double {
      match v {
        Some(Num(x)) => x
        _ => 200.0
      }
    }
    return (side(spec.width), side(spec.height))
  }
  let count = fn(p : Pos?) -> Int32 {
    match p {
      Some(q) if q.discrete => sorted_unique(data, q.field).length()
      _ => 0
    }
  }
  (
    axis_size(u.px, count(u.px), u.mark, step_of(spec.width), 200.0, spec.width),
    axis_size(
      u.py,
      count(u.py),
      u.mark,
      step_of(spec.height),
      200.0,
      spec.height,
    ),
  )
}

///|
/// The size of the plot area along one axis: `step * bandspace` for a discrete
/// scale with `n_discrete` domain values, the given size or 200 for a
/// continuous one, and 20 when the channel is missing.
fn axis_size(
  p : Pos?,
  n_discrete : Int32,
  mark : String,
  step : Double?,
  dflt : Double,
  spec_size : @value.Value?,
) -> Double {
  match p {
    Some(q) if q.discrete => {
      let n = n_discrete.to_double()
      let (pi, po) = if mark == "bar" {
        (0.1, 0.05)
      } else if mark == "tick" {
        (0.25, 0.125)
      } else if mark == "rect" || mark == "rule" {
        (0.0, 0.0)
      } else {
        (1.0, 0.5)
      }
      let s = match step {
        Some(v) => v
        None => 20.0
      }
      if mark == "bar" || mark == "tick" || mark == "rect" || mark == "rule" {
        @scale.band_space(n, pi, po) * s
      } else {
        @scale.band_space(n, 1.0, 0.5) * s
      }
    }
    Some(_) =>
      match spec_size {
        Some(Num(v)) => v
        _ => dflt
      }
    None =>
      match spec_size {
        Some(Num(v)) => v
        _ => 20.0
      }
  }
}

///|
/// Position scales and the scales of the non-positional channels.
fn unit_scales(
  u : UnitModel,
  width : Double,
  height : Double,
  xs_shared? : (RScale, Array[@value.Value]),
  ys_shared? : (RScale, Array[@value.Value]),
) -> UnitScales {
  let spec = u.spec
  let mark = u.mark
  let px = u.px
  let py = u.py
  let px2 = u.px2
  let py2 = u.py2
  let pcolor = u.pcolor
  let psize = u.psize
  let popacity = u.popacity
  let pshape = u.pshape
  let color_value = u.color_value
  let bin_info = u.bin_info
  let orient = u.orient
  let config_zero = u.config_zero
  let data = u.data
  let xfields = u.xfields
  let yfields = u.yfields
  let x_order = u.x_order
  let y_order = u.y_order
  let xs = match xs_shared {
    Some(_) => xs_shared
    None =>
      match px {
        Some(p) =>
          Some(
            pos_scale(
              p,
              [(data, xfields)],
              mark,
              0.0,
              width,
              bin_info.get("x"),
              zero_of_chan(mark, orient, "x", px2 is Some(_), config_zero),
              x_order,
            ),
          )
        None => None
      }
  }
  let ys = match ys_shared {
    Some(_) => ys_shared
    None =>
      match py {
        Some(p) =>
          Some(
            pos_scale(
              p,
              [(data, yfields)],
              mark,
              if p.discrete {
                0.0
              } else {
                height
              },
              if p.discrete {
                height
              } else {
                0.0
              },
              bin_info.get("y"),
              zero_of_chan(mark, orient, "y", py2 is Some(_), config_zero),
              y_order,
            ),
          )
        None => None
      }
  }
  let base_color = match color_value {
    Some(c) => c
    None => mark_color(spec)
  }
  let color_scale = @scale.Ordinal::new()
  let color_is_quant = match pcolor {
    Some(c) => c.typ == "quantitative"
    None => false
  }
  let color_domain = match pcolor {
    Some(c) if !color_is_quant => sorted_unique(data, c.field)
    _ => []
  }
  let color_seq : @scale.Sequential? = match pcolor {
    Some(c) if color_is_quant =>
      match @scale.scheme_interpolator("blues") {
        Some(f) => {
          let sq = @scale.Sequential::new(Linear, f)
          match domain_extent(data, [c.field], false) {
            Some((lo, hi)) => sq.scale.domain = [lo, hi]
            None => ()
          }
          Some(sq)
        }
        _ => None
      }
    _ => None
  }
  set_color_range(
    color_scale,
    color_domain,
    match pcolor {
      Some(c) => c.typ == "ordinal"
      None => false
    },
  )
  // a binned channel's step is the plot size over the number of bins
  let bin_step = fn(chan : String, size : Double) -> Double? {
    match u.bin_info.get(chan) {
      Some((start, stop, step)) => Some(size / ((stop - start) / step))
      None => None
    }
  }
  let step_w = match bin_step("x", width) {
    Some(v) => v
    None =>
      match step_of(spec.width) {
        Some(v) => v
        None => 20.0
      }
  }
  let step_h = match bin_step("y", height) {
    Some(v) => v
    None =>
      match step_of(spec.height) {
        Some(v) => v
        None => 20.0
      }
  }
  let max_size = {
    let m = 0.95 * min(step_w, step_h)
    m * m
  }
  let size_scale = match psize {
    Some(p) => Some(extent_scale(data, p.field, true, 4.0, max_size))
    None => None
  }
  let opacity_scale = match popacity {
    Some(p) => Some(extent_scale(data, p.field, false, 0.3, 0.8))
    None => None
  }
  let shape_scale = @scale.Ordinal::new()
  let shape_domain = match pshape {
    Some(p) => sorted_unique(data, p.field)
    None => []
  }
  shape_scale.set_domain(shape_domain)
  shape_scale.set_range(symbol_shapes.map(fn(x) { Str(x) }))
  let theta_scale : @scale.Continuous? = match u.ptheta {
    Some(p) => {
      let c = @scale.Continuous::new(Linear)
      let fields = if u.stack_chan == Some("theta") {
        [p.field + "_start", p.field + "_end"]
      } else {
        [p.field]
      }
      match domain_extent(data, fields, true) {
        Some((lo, hi)) => c.domain = [lo, hi]
        None => ()
      }
      c.range = match p.def.scale {
        Some(m) =>
          match m.get("range") {
            Some(Arr([Num(a), Num(b)])) => [a, b]
            _ => [0.0, 6.283185307179586]
          }
        None => [0.0, 6.283185307179586]
      }
      Some(c)
    }
    None => None
  }
  {
    xs,
    ys,
    base_color,
    color_scale,
    color_domain,
    color_seq,
    size_scale,
    opacity_scale,
    shape_scale,
    shape_domain,
    theta_scale,
  }
}

///|
fn unit_axes(
  u : UnitModel,
  sc : UnitScales,
  width : Double,
  height : Double,
  x_orient~ : String = "bottom",
  y_orient~ : String = "left",
  with_x~ : Bool = true,
  with_y~ : Bool = true,
  with_grid~ : Bool = true,
) -> Array[Axis] raise CompileError {
  let px = u.px
  let py = u.py
  let stack_chan = u.stack_chan
  let stack_offset = u.stack_offset
  let xs = sc.xs
  let ys = sc.ys
  // axes
  let axes : Array[Axis] = []
  let tick_x = tick_count_of(px, width)
  let tick_y = tick_count_of(py, height)
  let grid_props = fn(
    axis : @spec.Guide,
    p : Pos?,
  ) -> Map[String, @value.Value] raise CompileError {
    let m : Map[String, @value.Value] = Map([
      ("grid", Bool(true)),
      ("domain", Bool(false)),
      ("labels", Bool(false)),
      ("aria", Bool(false)),
      ("maxExtent", Num(0.0)),
      ("minExtent", Num(0.0)),
      ("ticks", Bool(false)),
    ])
    match axis {
      Props(a) =>
        for k, v in a {
          if k.has_prefix("grid") && k != "grid" {
            m[k] = v
          }
        }
      _ => ()
    }
    match p {
      Some(q) =>
        match q.time_unit {
          Some(u) =>
            match unit_duration(unit_parts(u)) {
              Some(d) => m["tickMinStep"] = Num(d)
              None => ()
            }
          None => ()
        }
      None => ()
    }
    m
  }
  match (px, xs, ys) {
    (Some(p), Some((s, _)), _) =>
      if !p.discrete && p.def.bin is None {
        match ys {
          Some((gs, _)) =>
            axes.push(
              build_axis(
                {
                  scale: s,
                  domain: [],
                  orient: x_orient,
                  title: None,
                  props: grid_props(p.def.axis, Some(p)),
                  grid_scale: Some(gs),
                  tick_count: tick_x,
                },
                width,
                height,
              ),
            )
          None =>
            axes.push(
              build_axis(
                {
                  scale: s,
                  domain: [],
                  orient: x_orient,
                  title: None,
                  props: grid_props(p.def.axis, Some(p)),
                  grid_scale: None,
                  tick_count: tick_x,
                },
                width,
                height,
              ),
            )
        }
      }
    _ => ()
  }
  match (py, ys, xs) {
    (Some(p), Some((s, _)), _) =>
      if !p.discrete && p.def.bin is None {
        axes.push(
          build_axis(
            {
              scale: s,
              domain: [],
              orient: y_orient,
              title: None,
              props: grid_props(p.def.axis, Some(p)),
              grid_scale: match xs {
                Some((gs, _)) => Some(gs)
                None => None
              },
              tick_count: tick_y,
            },
            width,
            height,
          ),
        )
      }
    _ => ()
  }
  match (px, xs) {
    (Some(p), Some((s, d))) => {
      let props : Map[String, @value.Value] = Map([])
      if p.discrete {
        let user_angle = match p.def.axis {
          Props(m) =>
            match m.get("labelAngle") {
              Some(Num(a)) => Some(a)
              _ => None
            }
          _ => None
        }
        let angle = match user_angle {
          Some(a) => Some(a)
          None => if p.time_unit is None { Some(270.0) } else { None }
        }
        label_angle_props("x", angle, x_orient, props)
        if p.time_unit is Some(_) {
          props["labelOverlap"] = Bool(true)
        }
      } else {
        props["labelFlush"] = Bool(true)
        props["labelOverlap"] = Bool(true)
      }
      if stack_chan == Some("x") && stack_offset == "normalize" {
        props["format"] = Str(".0%")
      }
      time_axis_props(p, props)
      apply_axis_spec(props, p.def.axis)
      axes.push(
        build_axis(
          {
            scale: s,
            domain: d,
            orient: x_orient,
            title: if p.def.title is Some(None) || p.title == "" {
              None
            } else {
              Some(p.title)
            },
            props,
            grid_scale: None,
            tick_count: tick_x,
          },
          width,
          height,
        ),
      )
    }
    _ => ()
  }
  match (py, ys) {
    (Some(p), Some((s, d))) => {
      let props : Map[String, @value.Value] = Map([])
      if !p.discrete || p.time_unit is Some(_) {
        props["labelOverlap"] = if is_log(p) {
          Str("greedy")
        } else {
          Bool(true)
        }
      }
      match p.def.axis {
        Props(m) =>
          match m.get("labelAngle") {
            Some(Num(a)) => label_angle_props("y", Some(a), y_orient, props)
            _ => ()
          }
        _ => ()
      }
      if stack_chan == Some("y") && stack_offset == "normalize" {
        props["format"] = Str(".0%")
      }
      time_axis_props(p, props)
      apply_axis_spec(props, p.def.axis)
      axes.push(
        build_axis(
          {
            scale: s,
            domain: d,
            orient: y_orient,
            title: if p.def.title is Some(None) || p.title == "" {
              None
            } else {
              Some(p.title)
            },
            props,
            grid_scale: None,
            tick_count: tick_y,
          },
          width,
          height,
        ),
      )
    }
    _ => ()
  }
  let hidden : Array[String] = []
  match px {
    Some({ def: { axis: Off, .. }, .. }) => hidden.push(x_orient)
    _ => ()
  }
  match py {
    Some({ def: { axis: Off, .. }, .. }) => hidden.push(y_orient)
    _ => ()
  }
  axes.filter(fn(a) {
    !hidden.contains(a.orient) &&
    (with_x || a.orient != x_orient) &&
    (with_y || a.orient != y_orient) &&
    (with_grid || !a.has_grid)
  })
}

///|
fn unit_mark(
  u : UnitModel,
  sc : UnitScales,
  width : Double,
  height : Double,
  name~ : String = "marks",
) -> @scene.Mark raise CompileError {
  let spec = u.spec
  let mark = u.mark
  let px = u.px
  let py = u.py
  let px2 = u.px2
  let py2 = u.py2
  let pcolor = u.pcolor
  let psize = u.psize
  let popacity = u.popacity
  let pshape = u.pshape
  let ptext = u.ptext
  let ptheta = u.ptheta
  let color_value = u.color_value
  let size_value = u.size_value
  let opacity_value = u.opacity_value
  let shape_value = u.shape_value
  let orient = u.orient
  let vertical = u.vertical
  let stack_chan = u.stack_chan
  let valid_fields = u.valid_fields
  let data = u.data
  let xs = sc.xs
  let ys = sc.ys
  let base_color = sc.base_color
  let color_scale = sc.color_scale
  let color_seq = sc.color_seq
  let size_scale = sc.size_scale
  let opacity_scale = sc.opacity_scale
  let shape_scale = sc.shape_scale
  let default_opacity = mark_opacity(mark, spec)
  let color_of = fn(r : Map[String, @value.Value]) -> String {
    match pcolor {
      Some(c) if color_seq is Some(_) =>
        match color_seq.unwrap().map(@value.get_field(r, c.field).to_number()) {
          Some(v) => v
          None => base_color
        }
      Some(c) =>
        match color_scale.map(@value.get_field(r, c.field)) {
          Some(Str(v)) => v
          _ => base_color
        }
      None => base_color
    }
  }
  // opacity from the encoding: a mapped field or a constant
  let apply_opacity = fn(
    it : @scene.Item,
    r : Map[String, @value.Value],
  ) -> Unit {
    match (popacity, opacity_scale) {
      (Some(p), Some(sc)) =>
        match sc.map(@value.get_field(r, p.field).to_number()) {
          Some(v) => it.set_num("opacity", v)
          None => ()
        }
      _ =>
        match opacity_value {
          Some(v) => it.set_num("opacity", v)
          None => ()
        }
    }
  }
  let items : Array[@scene.Item] = []
  let pos_of = fn(
    s : (RScale, Array[@value.Value])?,
    r : Map[String, @value.Value],
    field : String,
  ) -> Double {
    match s {
      Some((sc, _)) =>
        match sc.map(@value.get_field(r, field)) {
          Some(v) => v
          None => @double.not_a_number
        }
      None => 0.0
    }
  }
  // where a mark sits on a channel: a binned field is drawn at the middle of
  // its bin, anything else at the mapped value
  let pos_at = fn(
    s : (RScale, Array[@value.Value])?,
    r : Map[String, @value.Value],
    p : Pos,
  ) -> Double {
    if p.def.bin is Some(_) {
      match s {
        Some((sc, _)) => {
          let a = @value.get_field(r, p.field).to_number()
          let b = @value.get_field(r, p.field + "_end").to_number()
          match sc.map(Num(0.5 * a + 0.5 * b)) {
            Some(v) => v
            None => @double.not_a_number
          }
        }
        None => 0.0
      }
    } else {
      pos_of(s, r, p.field)
    }
  }
  let zero_of = fn(s : (RScale, Array[@value.Value])?) -> Double {
    match s {
      Some((sc, _)) =>
        match sc.map(Num(0.0)) {
          Some(v) => v
          None => 0.0
        }
      None => 0.0
    }
  }
  for r in data {
    match mark {
      "bar" => {
        let it = mk_item([("fill", str(color_of(r)))])
        apply_opacity(it, r)
        let px_ = px.unwrap()
        let py_ = py.unwrap()
        if vertical {
          if is_ranged(mark, px_) {
            let sv = pos_of(xs, r, px_.field)
            let ev = pos_of(xs, r, px_.field + "_end")
            let d = (ev - sv).abs()
            let x2v = sv +
              (0.5 + (if d < 0.25 { -0.5 * (0.25 - d) } else { 0.5 }))
            let xv = ev +
              (0.5 + (if d < 0.25 { 0.5 * (0.25 - d) } else { -0.5 }))
            it.set_num("x2", x2v)
            it.set_num("x", xv)
            if xv > x2v {
              it.set_num("x", x2v)
              it.set_num("x2", xv)
            }
            it.set_num("width", it.num0("x2") - it.num0("x"))
          } else {
            it.set_num("x", pos_of(xs, r, px_.field))
            it.set_num("width", max(0.25, xs.unwrap().0.bandwidth()))
          }
          let (a, b) = if stack_chan == Some("y") {
            (
              pos_of(ys, r, py_.field + "_end"),
              pos_of(ys, r, py_.field + "_start"),
            )
          } else {
            match py2 {
              Some(q) => (pos_of(ys, r, py_.field), pos_of(ys, r, q.field))
              None => (pos_of(ys, r, py_.field), zero_of(ys))
            }
          }
          it.set_num("y", min(a, b))
          it.set_num("y2", max(a, b))
          it.set_num("height", (b - a).abs())
        } else {
          if is_ranged(mark, py_) {
            let sv = pos_of(ys, r, py_.field)
            let ev = pos_of(ys, r, py_.field + "_end")
            let d = (ev - sv).abs()
            let y2v = sv +
              (0.5 + (if d < 0.25 { 0.5 * (0.25 - d) } else { -0.5 }))
            let yv = ev +
              (0.5 + (if d < 0.25 { -0.5 * (0.25 - d) } else { 0.5 }))
            it.set_num("y2", y2v)
            it.set_num("y", yv)
            if yv > y2v {
              it.set_num("y", y2v)
              it.set_num("y2", yv)
            }
            it.set_num("height", it.num0("y2") - it.num0("y"))
          } else {
            it.set_num("y", pos_of(ys, r, py_.field))
            it.set_num("height", max(0.25, ys.unwrap().0.bandwidth()))
          }
          let (a, b) = if stack_chan == Some("x") {
            (
              pos_of(xs, r, px_.field + "_start"),
              pos_of(xs, r, px_.field + "_end"),
            )
          } else {
            match px2 {
              Some(q) => (pos_of(xs, r, px_.field), pos_of(xs, r, q.field))
              None => (pos_of(xs, r, px_.field), zero_of(xs))
            }
          }
          it.set_num("x", min(a, b))
          it.set_num("x2", max(a, b))
          it.set_num("width", (b - a).abs())
        }
        items.push(it)
      }
      "line" => {
        let it = mk_item([
          (
            "strokeWidth",
            num(
              match size_value {
                Some(v) => v
                None => 2.0
              },
            ),
          ),
          ("stroke", str(color_of(r))),
        ])
        it.set_num("x", pos_of(xs, r, px.unwrap().field) + band_half(xs))
        it.set_num("y", pos_of(ys, r, py.unwrap().field) + band_half(ys))
        match spec.mark.props.get("interpolate") {
          Some(v) => it.set("interpolate", v)
          None => ()
        }
        it.set(
          "defined",
          Bool(
            valid_fields.all(fn(f) { is_finite_valid(@value.get_field(r, f)) }),
          ),
        )
        items.push(it)
      }
      "area" => {
        let it = mk_item([
          ("orient", str(orient_or_vertical(orient))),
          ("fill", str(color_of(r))),
        ])
        let px_ = px.unwrap()
        let py_ = py.unwrap()
        if vertical {
          it.set_num("x", pos_of(xs, r, px_.field) + band_half(xs))
          let (a, b) = if stack_chan == Some("y") {
            (
              pos_of(ys, r, py_.field + "_end"),
              pos_of(ys, r, py_.field + "_start"),
            )
          } else {
            (pos_of(ys, r, py_.field), zero_of(ys))
          }
          it.set_num("y", a)
          it.set_num("y2", b)
          it.set_num("height", b - a)
        } else {
          it.set_num("y", pos_of(ys, r, py_.field) + band_half(ys))
          let (a, b) = if stack_chan == Some("x") {
            (
              pos_of(xs, r, px_.field + "_end"),
              pos_of(xs, r, px_.field + "_start"),
            )
          } else {
            (pos_of(xs, r, px_.field), zero_of(xs))
          }
          it.set_num("x", a)
          it.set_num("x2", b)
          it.set_num("width", b - a)
        }
        match spec.mark.props.get("interpolate") {
          Some(v) => it.set("interpolate", v)
          None => ()
        }
        it.set(
          "defined",
          Bool(
            valid_fields.all(fn(f) { is_finite_valid(@value.get_field(r, f)) }),
          ),
        )
        items.push(it)
      }
      "rule" => {
        let it = mk_item([
          (
            "stroke",
            str(
              match pcolor {
                Some(_) => color_of(r)
                None =>
                  match color_value {
                    Some(c) => c
                    None => "black"
                  }
              },
            ),
          ),
        ])
        match (px, py) {
          (Some(a), Some(b)) =>
            if py2 is Some(_) {
              it.set_num(
                "x",
                pos_of(xs, r, a.field) + xs.unwrap().0.bandwidth() * 0.5,
              )
              it.set_num("y", pos_of(ys, r, b.field))
              it.set_num("y2", pos_of(ys, r, py2.unwrap().field))
            } else {
              it.set_num(
                "y",
                pos_of(ys, r, b.field) + ys.unwrap().0.bandwidth() * 0.5,
              )
              it.set_num("x", pos_of(xs, r, a.field))
              it.set_num("x2", pos_of(xs, r, px2.unwrap().field))
            }
          (None, Some(b)) => {
            // a horizontal rule across the whole plot
            it.set_num("x", width)
            it.set_num("x2", 0.0)
            it.set_num(
              "y",
              pos_of(ys, r, b.field) + ys.unwrap().0.bandwidth() * 0.5,
            )
          }
          (Some(a), None) => {
            it.set_num(
              "x",
              pos_of(xs, r, a.field) + xs.unwrap().0.bandwidth() * 0.5,
            )
            it.set_num("y", 0.0)
            it.set_num("y2", height)
          }
          _ => raise CompileError("rule 需要 x 或 y 通道")
        }
        match size_value {
          Some(v) => it.set_num("strokeWidth", v)
          None => ()
        }
        match spec.mark.props.get("strokeDash") {
          Some(v) => it.set("strokeDash", v)
          None => ()
        }
        items.push(it)
      }
      "text" => {
        let it = mk_item([
          ("font", str("sans-serif")),
          ("fontSize", num(11.0)),
          (
            "fill",
            str(
              match pcolor {
                Some(_) => color_of(r)
                None =>
                  match color_value {
                    Some(c) => c
                    None => "black"
                  }
              },
            ),
          ),
          ("align", str("center")),
          ("baseline", str("middle")),
        ])
        // without a channel the position comes from the mark property
        let const_pos = fn(key : String, extent : Double) -> Double {
          match spec.mark.props.get(key) {
            Some(Num(v)) => v
            Some(Str(_)) => extent
            _ => extent * 0.5
          }
        }
        it.set_num(
          "x",
          match px {
            Some(p) => pos_at(xs, r, p) + xs.unwrap().0.bandwidth() * 0.5
            None => const_pos("x", width)
          },
        )
        it.set_num(
          "y",
          match py {
            Some(p) => pos_at(ys, r, p) + ys.unwrap().0.bandwidth() * 0.5
            None => const_pos("y", height)
          },
        )
        it.set_str(
          "text",
          match (ptext, spec.mark.props.get("text")) {
            (None, Some(Str(s))) => s
            _ => text_of(r, ptext)
          },
        )
        for k in ["align", "baseline", "dx", "dy", "fontSize"] {
          match spec.mark.props.get(k) {
            Some(v) => it.set(k, v)
            None => ()
          }
        }
        items.push(it)
      }
      "arc" => {
        let it = mk_item([("fill", str(color_of(r)))])
        apply_opacity(it, r)
        let radius = fn(key : String, default : Double) -> Double {
          match spec.mark.props.get(key) {
            Some(Num(v)) => v
            _ => default
          }
        }
        it.set_num("x", width * 0.5)
        it.set_num("y", height * 0.5)
        it.set_num(
          "outerRadius",
          radius("outerRadius", min(width, height) / 2.0),
        )
        it.set_num("innerRadius", radius("innerRadius", 0.0))
        // the stack runs clockwise from the top: a slice starts at its end value and ends at its start value
        match (sc.theta_scale, ptheta) {
          (Some(ts), Some(p)) => {
            let value = fn(suffix : String) -> Double {
              match ts.map(@value.get_field(r, p.field + suffix).to_number()) {
                Some(v) => v
                None => @double.not_a_number
              }
            }
            if stack_chan == Some("theta") {
              it.set_num("startAngle", value("_end"))
              it.set_num("endAngle", value("_start"))
            }
          }
          _ => ()
        }
        for k in ["padAngle", "cornerRadius", "strokeWidth"] {
          match spec.mark.props.get(k) {
            Some(v) => it.set(k, v)
            None => ()
          }
        }
        match spec.mark.props.get("stroke") {
          Some(v) => it.set("stroke", v)
          None => ()
        }
        items.push(it)
      }
      "rect" => {
        let it = mk_item([("fill", str(color_of(r)))])
        apply_opacity(it, r)
        // each direction: a range between two fields, the whole plot when the
        // channel is absent, otherwise one band / point cell
        match (px, px2) {
          (Some(a), Some(a2)) => {
            let (lo, hi) = (pos_of(xs, r, a.field), pos_of(xs, r, a2.field))
            it.set_num("x", min(lo, hi))
            it.set_num("x2", max(lo, hi))
            it.set_num("width", (hi - lo).abs())
          }
          (Some(a), None) => {
            it.set_num("x", pos_of(xs, r, a.field))
            it.set_num("width", max(0.25, xs.unwrap().0.bandwidth()))
          }
          (None, _) => {
            it.set_num("x", 0.0)
            it.set_num("x2", width)
            it.set_num("width", width)
          }
        }
        match (py, py2) {
          (Some(b), Some(b2)) => {
            let (lo, hi) = (pos_of(ys, r, b.field), pos_of(ys, r, b2.field))
            it.set_num("y", min(lo, hi))
            it.set_num("y2", max(lo, hi))
            it.set_num("height", (hi - lo).abs())
          }
          (Some(b), None) => {
            it.set_num("y", pos_of(ys, r, b.field))
            it.set_num("height", max(0.25, ys.unwrap().0.bandwidth()))
          }
          (None, _) => {
            it.set_num("y", 0.0)
            it.set_num("y2", height)
            it.set_num("height", height)
          }
        }
        items.push(it)
      }
      "tick" => {
        let it = mk_item([("fill", str(color_of(r)))])
        match default_opacity {
          Some(o) => it.set_num("opacity", o)
          None => ()
        }
        let thickness = 1.0
        if orient == "horizontal" {
          // size along x, thickness along y
          match xs {
            Some((sc, _)) => {
              it.set_num("x", pos_of(xs, r, px.unwrap().field))
              it.set_num("width", max(0.25, sc.bandwidth()))
            }
            None => {
              it.set_num("width", 0.75 * width)
              it.set_num("x", 0.0)
            }
          }
          let yc = pos_of(ys, r, py.unwrap().field)
          it.set_num("yc", yc)
          it.set_num("height", thickness)
          it.set_num("y", yc - thickness / 2.0)
        } else {
          match ys {
            Some((sc, _)) => {
              it.set_num("y", pos_of(ys, r, py.unwrap().field))
              it.set_num("height", max(0.25, sc.bandwidth()))
            }
            None => {
              let h = 0.75 * height
              it.set_num("yc", height * 0.5)
              it.set_num("height", h)
              it.set_num("y", height * 0.5 - h / 2.0)
            }
          }
          let xc = pos_of(xs, r, px.unwrap().field)
          it.set_num("xc", xc)
          it.set_num("width", thickness)
          it.set_num("x", xc - thickness / 2.0)
        }
        items.push(it)
      }
      _ => {
        // point, circle, square
        let filled_prop = match spec.mark.props.get("filled") {
          Some(Bool(true)) => true
          _ => false
        }
        let filled = mark != "point" || filled_prop
        let it = mk_item([
          ("size", num(30.0)),
          ("strokeWidth", num(2.0)),
          ("shape", str(if mark == "point" { "circle" } else { mark })),
        ])
        match default_opacity {
          Some(o) => it.set_num("opacity", o)
          None => ()
        }
        if filled {
          it.set_str("fill", color_of(r))
        } else {
          it.set_str("fill", "transparent")
          it.set_str("stroke", color_of(r))
        }
        let bx = match xs {
          Some((sc, _)) => sc.bandwidth() * 0.5
          None => 0.0
        }
        let by = match ys {
          Some((sc, _)) => sc.bandwidth() * 0.5
          None => 0.0
        }
        let xv = match px {
          Some(p) => pos_at(xs, r, p) + bx
          None => width * 0.5
        }
        let yv = match py {
          Some(p) => pos_at(ys, r, p) + by
          None => height * 0.5
        }
        it.set_num("x", xv)
        it.set_num("y", yv)
        match (psize, size_scale) {
          (Some(p), Some(sc)) =>
            match sc.map(@value.get_field(r, p.field).to_number()) {
              Some(v) => it.set_num("size", v)
              None => ()
            }
          _ =>
            match size_value {
              Some(v) => it.set_num("size", v)
              None => ()
            }
        }
        apply_opacity(it, r)
        match pshape {
          Some(p) =>
            match shape_scale.map(@value.get_field(r, p.field)) {
              Some(v) => it.set("shape", v)
              None => ()
            }
          None =>
            match shape_value {
              Some(v) => it.set_str("shape", v)
              None => ()
            }
        }
        items.push(it)
      }
    }
  }
  match spec.mark.props.get("opacity") {
    Some(Num(o)) =>
      if !(mark == "point" ||
        mark == "circle" ||
        mark == "square" ||
        mark == "tick") {
        for it in items {
          it.set_num("opacity", o)
        }
      }
    _ => ()
  }
  let mt = match mark {
    "point" | "circle" | "square" => @scene.MarkType::Symbol
    "line" => @scene.MarkType::Line
    "area" => @scene.MarkType::Area
    "rule" => @scene.MarkType::Rule
    "text" => @scene.MarkType::Text
    "arc" => @scene.MarkType::Arc
    _ => @scene.MarkType::Rect
  }
  // the series (colour) of every item, kept in step with `items`
  let item_keys : Array[String] = match pcolor {
    Some(c) =>
      data.map(fn(r) { @scale.value_key(@value.get_field(r, c.field)) })
    None => data.map(fn(_) { "" })
  }
  // the series appear in the order of the data, whatever the sort of the paths
  let series_order : Array[String] = []
  for k in item_keys {
    if !series_order.contains(k) {
      series_order.push(k)
    }
  }
  if mark == "line" || mark == "area" {
    // `sort: {field: "x"}`
    let sort_key = if vertical { "x" } else { "y" }
    let idx = Array::makei(items.length(), fn(i) { i })
    let sorted = @transform.stable_sort(idx, fn(a, b) {
      @value.compare_values(items[a].get(sort_key), items[b].get(sort_key))
    })
    let sorted_items = sorted.map(fn(i) { items[i] })
    let sorted_keys = sorted.map(fn(i) { item_keys[i] })
    items.clear()
    for it in sorted_items {
      items.push(it)
    }
    item_keys.clear()
    for k in sorted_keys {
      item_keys.push(k)
    }
  }
  let marks = match pcolor {
    Some(_) if mark == "line" || mark == "area" => {
      // one path per colour, each in its own group (vega-lite's `pathgroup`)
      let order = series_order
      let by_key : Map[String, Array[@scene.Item]] = Map([])
      for i in 0.. a.push(items[i])
          None => by_key[k] = [items[i]]
        }
      }
      let group_name = if name.has_suffix("marks") {
        name[0:name.length() - 5].to_owned() + "pathgroup"
      } else {
        name
      }
      let group_items = order.map(fn(k) {
        let g = mk_item([("width", num(width)), ("height", num(height))])
        g.children.push(
          @scene.Mark::new(mt, name~, role="mark", items=by_key[k]),
        )
        g
      })
      @scene.Mark::new(Group, name=group_name, role="scope", items=group_items)
    }
    _ => @scene.Mark::new(mt, name~, role="mark", items~)
  }
  marks
}

///|
/// Whether a mark is drawn with a fill (vega-lite's `filled`): everything but
/// `point`, `line` and `rule` unless it says so.
fn is_filled_mark(mark : String, spec : @spec.Spec) -> Bool {
  !(mark == "point" || mark == "line" || mark == "rule") ||
  (match spec.mark.props.get("filled") {
    Some(Bool(true)) => true
    _ => false
  })
}

///|
fn unit_legends(
  u : UnitModel,
  sc : UnitScales,
  height : Double,
  skip_color~ : Bool = false,
  shape_override? : String,
  opacity_override? : Double,
  color_channels? : Array[String],
) -> Array[Legend] raise CompileError {
  let spec = u.spec
  let default_opacity = mark_opacity(u.mark, spec)
  let mark = u.mark
  let pcolor = u.pcolor
  let psize = u.psize
  let popacity = u.popacity
  let pshape = u.pshape
  let base_color = sc.base_color
  let color_scale = sc.color_scale
  let color_domain = sc.color_domain
  let color_seq = sc.color_seq
  let size_scale = sc.size_scale
  let opacity_scale = sc.opacity_scale
  let shape_scale = sc.shape_scale
  let shape_domain = sc.shape_domain
  let base_color = base_color
  let filled_mark = is_filled_mark(mark, spec)
  let leg_chs : Array[LegCh] = []
  match pcolor {
    Some(c) if !(c.def.legend is Off) && color_seq is None && !skip_color =>
      // layers that merge into one legend may colour through both channels
      for
        ch in color_channels.unwrap_or([
          if filled_mark {
            "fill"
          } else {
            "stroke"
          },
        ]) {
        leg_chs.push({
          channel: ch,
          pos: c,
          kind: Disc(color_scale, color_domain),
        })
      }
    _ => ()
  }
  match (psize, size_scale) {
    (Some(p), Some(sc)) =>
      leg_chs.push({ channel: "size", pos: p, kind: Cont(sc), })
    _ => ()
  }
  match (pshape, ()) {
    (Some(p), _) =>
      leg_chs.push({
        channel: "shape",
        pos: p,
        kind: Disc(shape_scale, shape_domain),
      })
    _ => ()
  }
  match (popacity, opacity_scale) {
    (Some(p), Some(sc)) =>
      leg_chs.push({ channel: "opacity", pos: p, kind: Cont(sc), })
    _ => ()
  }
  let legends = legends_of(
    leg_chs,
    mark,
    filled_mark,
    base_color,
    pcolor is Some(_),
    match opacity_override {
      Some(_) => opacity_override
      None => default_opacity
    },
    shape_override?,
    mark_stroke?=match spec.mark.props.get("stroke") {
      Some(Str(c)) => Some(c)
      _ => None
    },
  )
  match (pcolor, color_seq) {
    (Some(c), Some(sq)) =>
      if !(c.def.legend is Off) && !skip_color {
        legends.insert(
          0,
          build_gradient_legend(
            Some(c.title),
            sq,
            max(64.0, min(200.0, height)),
            match default_opacity {
              Some(o) => o
              None => 1.0
            },
            None,
          ),
        )
      }
    _ => ()
  }
  legends
}

///|
pub fn compile(text : String) -> Compiled raise CompileError {
  let spec = @spec.parse_spec_text(text) catch { e => raise spec_err(e) }
  if spec.layer.length() > 0 {
    return compile_layers(spec)
  }
  validate(spec)
  let u = build_unit(spec)
  let (width, height) = unit_size(u)
  let sc = unit_scales(u, width, height)
  let axes = unit_axes(u, sc, width, height)
  let marks = unit_mark(u, sc, width, height)
  let legends = unit_legends(u, sc, height)
  let title = match spec.title {
    Some(t) => Some(build_title(t))
    None => None
  }
  assemble(
    spec,
    axes,
    [marks],
    legends,
    title,
    width,
    height,
    framed=u.px is Some(_) || u.py is Some(_),
  )
}

///|
/// Apply the user's `axis` object of a channel on top of the defaults.
fn apply_axis_spec(
  props : Map[String, @value.Value],
  axis : @spec.Guide,
) -> Unit {
  match axis {
    Props(m) =>
      for k, v in m {
        props[k] = v
      }
    _ => ()
  }
}

///|
fn is_measure(p : Pos?) -> Bool {
  match p {
    Some(q) => q.typ == "quantitative" && q.def.bin is None
    None => false
  }
}

///|
/// vega-lite's mark orientation (`compile/mark/init.ts`) for the supported marks.
fn infer_orient(mark : String, px : Pos?, py : Pos?) -> String {
  match mark {
    "point" | "circle" | "square" | "text" | "rect" => ""
    _ => {
      if mark == "bar" {
        match (px, py) {
          (Some(x), Some(y)) =>
            if y.aggregate is Some(_) && x.aggregate is None {
              return "vertical"
            } else if x.aggregate is Some(_) && y.aggregate is None {
              return "horizontal"
            }
          _ => ()
        }
      }
      let xm = is_measure(px)
      let ym = is_measure(py)
      if xm && !ym {
        if mark != "tick" {
          "horizontal"
        } else {
          "vertical"
        }
      } else if !xm && ym {
        if mark != "tick" {
          "vertical"
        } else {
          "horizontal"
        }
      } else if xm && ym {
        "vertical"
      } else {
        let xt = match px {
          Some(p) => p.temporal
          None => false
        }
        let yt = match py {
          Some(p) => p.temporal
          None => false
        }
        if xt && !yt {
          "vertical"
        } else if !xt && yt {
          "horizontal"
        } else {
          ""
        }
      }
    }
  }
}

///|
/// Axis properties implied by a time unit: its format, format type and the
/// minimum tick step.
fn time_axis_props(
  p : Pos,
  props : Map[String, @value.Value],
) -> Unit raise CompileError {
  match p.time_unit {
    Some(u) => {
      let parts = unit_parts(u)
      props["format"] = Str(time_unit_specifier(parts))
      if p.discrete {
        props["formatType"] = Str("time")
      }
      match unit_duration(parts) {
        Some(d) => props["tickMinStep"] = Num(d)
        None => ()
      }
    }
    None => ()
  }
}

///|
/// `labelAngle` and the label alignment / baseline that follow from it
/// (vega-lite `defaultLabelAlign` / `defaultLabelBaseline`).
fn label_angle_props(
  chan : String,
  angle : Double?,
  orient : String,
  props : Map[String, @value.Value],
) -> Unit {
  match angle {
    None => ()
    Some(a0) => {
      let a = (a0 % 360.0 + 360.0) % 360.0
      props["labelAngle"] = Num(a)
      if chan == "x" {
        if a % 180.0 != 0.0 {
          let left = (0.0 < a && a < 180.0) == (orient == "bottom")
          props["labelAlign"] = Str(if left { "left" } else { "right" })
        }
        if (45.0 < a && a < 135.0) || (225.0 < a && a < 315.0) {
          props["labelBaseline"] = Str("middle")
        } else {
          let bottom = (a <= 45.0 || 315.0 <= a) == (orient == "top")
          props["labelBaseline"] = Str(if bottom { "bottom" } else { "top" })
        }
      } else {
        if (a + 90.0) % 180.0 == 0.0 {
          props["labelAlign"] = Str("center")
        } else {
          let left = (90.0 < a && a < 270.0) == (orient == "left")
          props["labelAlign"] = Str(if left { "left" } else { "right" })
        }
        if !(a <= 45.0 || 315.0 <= a || (135.0 <= a && a <= 225.0)) {
          let top = (45.0 <= a && a <= 135.0) == (orient == "left")
          props["labelBaseline"] = Str(if top { "top" } else { "bottom" })
        }
      }
    }
  }
}

///|
/// vega-lite's default `tickCount` of a continuous position axis.
fn tick_count_of(p : Pos?, size : Double) -> Double {
  match p {
    Some(q) =>
      if is_log(q) {
        10.0
      } else if q.def.bin is Some(_) {
        ceil(size / 10.0)
      } else {
        match q.time_unit {
          Some("month") | Some("hours") | Some("day") | Some("quarter") => 10.0
          _ => ceil(size / 40.0)
        }
      }
    None => ceil(size / 40.0)
  }
}

///|
/// Bars over a time unit span from the unit's start to its end.
fn is_ranged(mark : String, p : Pos) -> Bool {
  mark == "bar" &&
  ((p.temporal && p.time_unit is Some(_)) || p.def.bin is Some(_))
}

///|
fn orient_or_vertical(o : String) -> String {
  if o == "" {
    "vertical"
  } else {
    o
  }
}

///|
/// The string a text mark shows for a row.
fn text_of(
  r : Map[String, @value.Value],
  p : Pos?,
) -> String raise CompileError {
  match p {
    None => ""
    Some(q) => {
      let v = @value.get_field(r, q.field)
      match q.def.format {
        Some(f) =>
          (@format.format(f) catch {
            _ => raise CompileError("text.format 无效: " + f)
          })(v.to_number())
        None => v.to_js_string()
      }
    }
  }
}

///|
/// Titles of a range channel and its second channel, joined unless equal.
fn merge_titles(a : String, b : String) -> String {
  if a == b {
    a
  } else {
    a + ", " + b
  }
}

///|
/// vega-lite's default of the `zero` scale property for a position channel.
fn zero_of_chan(
  mark : String,
  orient : String,
  chan : String,
  secondary : Bool,
  config_zero : Bool,
) -> Bool {
  let perpendicular = (mark == "bar" || mark == "area" || mark == "line") &&
    (
      (orient == "horizontal" && chan == "y") ||
      (orient == "vertical" && chan == "x")
    )
  if perpendicular {
    false
  } else if (mark == "bar" || mark == "area") && !secondary {
    true
  } else {
    config_zero
  }
}

///|
fn is_log(p : Pos) -> Bool {
  match p.def.scale {
    Some(m) =>
      match m.get("type") {
        Some(Str("log")) => true
        _ => false
      }
    None => false
  }
}

///|
/// Half the bandwidth of a band scale (a path mark through the centre of its
/// band); 0 for point and continuous scales.
fn band_half(s : (RScale, Array[@value.Value])?) -> Double {
  match s {
    Some((sc, _)) => sc.bandwidth() * 0.5
    None => 0.0
  }
}

///|
/// The opacity of a unit's marks: the `opacity` mark property, else 0.7 for
/// point-like marks unless the unit aggregates a field (vega-lite's default).
fn mark_opacity(mark : String, spec : @spec.Spec) -> Double? {
  match spec.mark.props.get("opacity") {
    Some(Num(o)) => Some(o)
    _ =>
      if (
          mark == "point" ||
          mark == "circle" ||
          mark == "square" ||
          mark == "tick"
        ) &&
        !spec.encoding.any(fn(e) { e.1.aggregate is Some(_) }) {
        Some(0.7)
      } else {
        None
      }
  }
}

///|
/// The shape of the legend symbol that a mark gives to a colour legend.
fn legend_symbol_shape(mark : String) -> String {
  match mark {
    "bar" | "rect" | "square" => "square"
    "line" | "rule" => "stroke"
    _ => "circle"
  }
}