// 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.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"
        }
      }
  }
  let title = match def.title {
    Some(Some(t)) => t
    _ => default_title(def)
  }
  {
    def,
    typ,
    field: 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(
  rows : Array[Map[String, @value.Value]],
  fields : Array[String],
  zero : Bool,
) -> (Double, Double)? {
  let mut lo = @double.infinity
  let mut hi = @double.neg_infinity
  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 fields.length() > 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))
}

///|
/// 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,
  rows : Array[Map[String, @value.Value]],
  domain_fields : 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(rows, p.field)
    }
    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(rows, domain_fields, 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(rows, domain_fields, 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), [])
  }
}

///|
pub fn compile(text : String) -> Compiled raise CompileError {
  let spec = @spec.parse_spec_text(text) catch { e => raise spec_err(e) }
  validate(spec)
  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" {
    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 = 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({ field: Some(_), .. } as d) => 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)) => Some({ ..a, title: merge_titles(a.title, b.title), })
    _ => px
  }
  let py = match (py, py2) {
    (Some(a), Some(b)) => Some({ ..a, title: merge_titles(a.title, b.title), })
    _ => py
  }
  let psize : Pos? = match find_channel(spec, "size") {
    Some({ field: Some(_), .. } as d) => Some(resolve_pos("size", d))
    _ => None
  }
  let popacity : Pos? = match find_channel(spec, "opacity") {
    Some({ field: Some(_), .. } as d) => Some(resolve_pos("opacity", d))
    _ => None
  }
  let pshape : Pos? = match find_channel(spec, "shape") {
    Some({ field: Some(_), .. } as d) => Some(resolve_pos("shape", d))
    _ => None
  }
  let size_value : Double? = match find_channel(spec, "size") {
    Some({ value: Some(Num(v)), .. }) => Some(v)
    _ => 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"] {
      if find_channel(spec, ch) is Some(_) &&
        !(ch == "opacity" && (mark == "bar" || mark == "tick" || mark == "area")) {
        raise CompileError(
          "encoding." + ch + " 暂不支持用于 mark " + mark,
        )
      }
    }
  }
  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] {
    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) {
          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
  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") &&
    !(has_aggregate && pcolor is None) {
    match (px, py) {
      (Some(x), Some(y)) =>
        if x.typ == "quantitative" && y.typ != "quantitative" {
          Some("x")
        } else if y.typ == "quantitative" && x.typ != "quantitative" {
          Some("y")
        } else {
          None
        }
      _ => None
    }
  } else {
    None
  }
  let stack_req : String? = match stack_chan0 {
    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("y") => py.unwrap().field
    Some("x") => px.unwrap().field
    _ => ""
  }
  match stack_chan {
    Some(c) => {
      let dim = if c == "y" { px.unwrap().field } else { py.unwrap().field }
      @transform.stack(
        rows,
        Some(stacked_field),
        [dim],
        match pcolor {
          Some(c) => Some({ fields: [c.field], descending: [true], })
          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] {
    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)) })
    })
  }
  // view sizes
  let xstep = step_of(spec.width)
  let ystep = step_of(spec.height)
  let width_of = fn(
    p : Pos?,
    step : Double?,
    dflt : Double,
    spec_size : @value.Value?,
  ) -> Double {
    match p {
      Some(q) if q.discrete => {
        let n = sorted_unique(data, q.field).length().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
        }
    }
  }
  let width = width_of(px, xstep, 200.0, spec.width)
  let height = width_of(py, ystep, 200.0, spec.height)
  // 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
  }
  let xs = 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 py {
    Some(p) =>
      if p.discrete {
        Some(
          pos_scale(
            p,
            data,
            yfields,
            mark,
            0.0,
            height,
            bin_info.get("y"),
            zero_of_chan(mark, orient, "y", py2 is Some(_), config_zero),
            y_order,
          ),
        )
      } else {
        Some(
          pos_scale(
            p,
            data,
            yfields,
            mark,
            height,
            0.0,
            bin_info.get("y"),
            zero_of_chan(mark, orient, "y", py2 is Some(_), config_zero),
            y_order,
          ),
        )
      }
    None => None
  }
  // 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: "bottom",
                  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: "bottom",
                  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: "left",
              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, "bottom", 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: "bottom",
            title: if p.def.title is Some(None) {
              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), "left", 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: "left",
            title: if p.def.title is Some(None) {
              None
            } else {
              Some(p.title)
            },
            props,
            grid_scale: None,
            tick_count: tick_y,
          },
          width,
          height,
        ),
      )
    }
    _ => ()
  }
  // marks
  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
  }
  color_scale.set_domain(color_domain)
  match @scale.scheme("tableau10") {
    Some(Discrete(cs)) => color_scale.set_range(cs.map(fn(c) { Str(c) }))
    _ => ()
  }
  let step_w = match step_of(spec.width) {
    Some(v) => v
    None => 20.0
  }
  let step_h = 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 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
    }
  }
  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
    }
  }
  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)))])
        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 {
            (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 {
          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 {
            (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(2.0)),
          ("stroke", str(color_of(r))),
        ])
        it.set_num("x", pos_of(xs, r, px.unwrap().field))
        it.set_num("y", pos_of(ys, r, py.unwrap().field))
        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))
          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))
          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 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))
            }
          _ => raise CompileError("rule 需要 x 与 y 通道")
        }
        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")),
        ])
        it.set_num(
          "x",
          match px {
            Some(p) => pos_of(xs, r, p.field) + xs.unwrap().0.bandwidth() * 0.5
            None => width * 0.5
          },
        )
        it.set_num(
          "y",
          match py {
            Some(p) => pos_of(ys, r, p.field) + ys.unwrap().0.bandwidth() * 0.5
            None => height * 0.5
          },
        )
        it.set_str("text", text_of(r, ptext))
        items.push(it)
      }
      "rect" => {
        let it = mk_item([("fill", str(color_of(r)))])
        let a = px.unwrap()
        let b = py.unwrap()
        it.set_num("x", pos_of(xs, r, a.field))
        it.set_num("width", max(0.25, xs.unwrap().0.bandwidth()))
        it.set_num("y", pos_of(ys, r, b.field))
        it.set_num("height", max(0.25, ys.unwrap().0.bandwidth()))
        items.push(it)
      }
      "tick" => {
        let it = mk_item([("opacity", num(0.7)), ("fill", str(color_of(r)))])
        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 })),
          ("opacity", num(0.7)),
        ])
        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_of(xs, r, p.field) + bx
          None => width * 0.5
        }
        let yv = match py {
          Some(p) => pos_of(ys, r, p.field) + 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 => ()
            }
        }
        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 => ()
            }
        }
        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)
      }
    }
  }
  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
    _ => @scene.MarkType::Rect
  }
  if mark == "line" || mark == "area" {
    // `sort: {field: "x"}`
    let sort_key = if vertical { "x" } else { "y" }
    let sorted = @transform.stable_sort(items, fn(a, b) {
      @value.compare_values(a.get(sort_key), b.get(sort_key))
    })
    items.clear()
    for it in sorted {
      items.push(it)
    }
  }
  let marks = @scene.Mark::new(mt, name="marks", role="mark", items~)
  let filled_mark = mark != "point" ||
    (match spec.mark.props.get("filled") {
      Some(Bool(true)) => true
      _ => false
    })
  let leg_chs : Array[LegCh] = []
  match pcolor {
    Some(c) if !(c.def.legend is Off) && color_seq is None =>
      leg_chs.push({
        channel: if filled_mark {
          "fill"
        } else {
          "stroke"
        },
        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 (pcolor, color_seq) {
    (Some(c), Some(sq)) =>
      if !(c.def.legend is Off) {
        legends.insert(
          0,
          build_gradient_legend(
            Some(c.title),
            sq,
            max(64.0, min(200.0, height)),
            if mark == "point" ||
              mark == "circle" ||
              mark == "square" ||
              mark == "tick" {
              0.7
            } else {
              1.0
            },
            None,
          ),
        )
      }
    _ => ()
  }
  let title = match spec.title {
    Some(t) => Some(build_title(t))
    None => None
  }
  let hidden : Array[String] = []
  match px {
    Some({ def: { axis: Off, .. }, .. }) => hidden.push("bottom")
    _ => ()
  }
  match py {
    Some({ def: { axis: Off, .. }, .. }) => hidden.push("left")
    _ => ()
  }
  let axes = axes.filter(fn(a) { !hidden.contains(a.orient) })
  assemble(spec, axes, [marks], legends, title, width, height)
}

///|
/// 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
  }
}