// Symbol legends: vega-parser `legend.js`, `legend-symbol-groups.js`,
// `legend-title.js` and the legend layout of vega-view-transforms.
///|
/// One legend entry: its label and the symbol properties that depend on the
/// value (`fill` / `stroke` / `size` / `shape` / `opacity` ...).
pub(all) struct LegendEntry {
label : String
props : Map[String, @value.Value]
}
///|
pub(all) struct LegendPlan {
title : String?
orient : String
/// symbol properties shared by every entry (`shape`, `size`, `fill`, ...)
symbol : Map[String, @value.Value]
entries : Array[LegendEntry]
}
///|
pub(all) struct Legend {
mark : @scene.Mark
orient : String
/// the group items of the entries and the mark holding them
entry_items : Array[@scene.Item]
scope_mark : @scene.Mark
entry_mark : @scene.Mark
title_mark : @scene.Mark?
}
///|
fn legend_item(l : Legend) -> @scene.Item {
l.mark.items[0]
}
///|
fn legend_entry_group(l : Legend) -> @scene.Item {
l.entry_mark.items[0]
}
///|
pub fn build_legend(plan : LegendPlan) -> Legend raise CompileError {
let label_size = 10.0
let size = match plan.symbol.get("size") {
Some(Num(v)) => v
_ => 100.0
}
let stroke_width = match plan.symbol.get("strokeWidth") {
Some(Num(v)) => v
_ => 1.5
}
// per-entry symbol extent: max(ceil(sqrt(size) + strokeWidth), fontSize)
let sizes = plan.entries.map(fn(e) {
let sz = match e.props.get("size") {
Some(Num(v)) => v
_ => size
}
max(ceil(sz.sqrt() + stroke_width), label_size)
})
let mut offset = 0.0
for v in sizes {
offset = max(offset, v)
}
let entry_items : Array[@scene.Item] = []
let n = plan.entries.length()
for i in 0.. {
let it = text_item(
[
("opacity", num(1.0)),
("orient", str("top")),
("text", str(t)),
("limit", num(180.0)),
("x", num(0.0)),
("y", num(0.0)),
("anchor", str("start")),
("angle", num(0.0)),
("align", str("left")),
("baseline", str("top")),
],
"guide-title",
)
let m = @scene.Mark::new(Text, role="legend-title", interactive=false, items=[
it,
])
item.children.push(m)
Some(m)
}
None => None
}
let mark = @scene.Mark::new(Group, role="legend", interactive=false, items=[
item,
])
let lg = {
mark,
orient: plan.orient,
entry_items,
scope_mark,
entry_mark,
title_mark,
}
try {
// bound entries, lay them out in a column, then bound the legend
@scene.bound_mark(scope_mark)
ignore(
grid_layout(entry_items, entry_items.map(fn(_) { scope_mark }), {
align_col: "each",
align_row: "each",
pad_col: 10.0,
pad_row: 2.0,
columns: 1,
center_col: false,
center_row: true,
flush: false,
anchor_x: 0.0,
anchor_y: 0.0,
anchor_col: "",
anchor_row: "",
}),
)
ignore(entry_group.bounds.clear())
ignore(@scene.bound_group(entry_group.bounds, entry_group))
ignore(entry_mark.bounds.clear().union(entry_group.bounds))
match title_mark {
Some(m) => @scene.bound_mark(m)
None => ()
}
ignore(item.bounds.clear())
ignore(@scene.bound_group(item.bounds, item))
ignore(mark.bounds.clear().union(item.bounds))
} catch {
@scene.SceneError(m) => raise CompileError(m)
}
lg
}
// ---- layout ----
///|
fn translate_item(
item : @scene.Item,
mark : @scene.Mark,
dx : Double,
dy : Double,
) -> Unit {
item.set_num("x", item.num0("x") + dx)
item.set_num("y", item.num0("y") + dy)
ignore(item.bounds.translate(dx, dy))
ignore(mark.bounds.translate(dx, dy))
}
///|
fn legend_title_offset(
item : @scene.Item,
entry : @scene.Item,
title : @scene.Item,
anchor : String,
y : Bool,
lr : Bool,
no_bar : Bool,
) -> Double {
ignore(lr)
ignore(no_bar)
let e = entry
let s = (if y { e.bounds.y2 } else { e.bounds.x2 }) - item.num0("padding")
let o = if !y { 0.0 } else { @scene.multi_line_offset(title) }
match anchor {
"start" => round(0.0)
"end" => round(s - o)
_ => round(0.5 * (s - o))
}
}
///|
/// vega `legendLayout`: pad the legend, place the title and size the group.
fn legend_layout(l : Legend) -> Unit {
let item = legend_item(l)
let entry = legend_entry_group(l)
let pad = item.num0("padding")
// cache the current bounds
item.set(
"_bounds",
Obj(
Map::from_array([
("x1", Num(item.bounds.x1)),
("y1", Num(item.bounds.y1)),
("x2", Num(item.bounds.x2)),
("y2", Num(item.bounds.y2)),
]),
),
)
let mut ex = pad - entry.num0("x")
let mut ey = pad - entry.num0("y")
match l.title_mark {
None =>
if ex != 0.0 || ey != 0.0 {
translate_item(entry, l.entry_mark, ex, ey)
}
Some(tm) => {
let title = tm.items[0]
let anchor = title.str("anchor").unwrap_or("start")
let tpad = item.num0("titlePadding")
let mut tx = pad - title.num0("x")
let mut ty = pad - title.num0("y")
match title.str("orient") {
Some("left") => ex += ceil(title.bounds.width()) + tpad
Some("right") | Some("bottom") => ()
_ => ey += title.bounds.height() + tpad
}
if ex != 0.0 || ey != 0.0 {
translate_item(entry, l.entry_mark, ex, ey)
}
match title.str("orient") {
Some("left") =>
ty += legend_title_offset(
item, entry, title, anchor, true, true, false,
)
Some("right") => {
tx += legend_title_offset(
item, entry, title, "end", false, false, false,
) +
tpad
ty += legend_title_offset(
item, entry, title, anchor, true, true, false,
)
}
Some("bottom") => {
tx += legend_title_offset(
item, entry, title, anchor, false, false, false,
)
ty += legend_title_offset(
item, entry, title, "end", true, false, true,
) +
tpad
}
_ =>
tx += legend_title_offset(
item, entry, title, anchor, false, false, false,
)
}
if tx != 0.0 || ty != 0.0 {
translate_item(title, tm, tx, ty)
}
let t = round(title.bounds.x1 - pad)
if t < 0.0 {
translate_item(entry, l.entry_mark, -t, 0.0)
translate_item(title, tm, -t, 0.0)
}
}
}
// aggregate bounds to determine the size
let b = @scene.Bounds::new()
ignore(b.union(l.entry_mark.bounds))
match l.title_mark {
Some(tm) => ignore(b.union(tm.bounds))
None => ()
}
b.x1 = pad
b.y1 = pad
let mut w = 2.0 * pad
let mut h = 2.0 * pad
if !b.is_empty() {
w = ceil(b.width() + w)
h = ceil(b.height() + h)
}
// symbol entries get uniform column widths and their own height
let widths : Map[Int32, Double] = Map([])
for g in l.entry_items {
let c = g.num0("column").to_int()
let cur = match widths.get(c) {
Some(v) => v
None => 0.0
}
widths[c] = max(g.bounds.x2 - g.num0("x"), cur)
}
for g in l.entry_items {
g.set_num("width", widths[g.num0("column").to_int()])
g.set_num("height", g.bounds.y2 - g.num0("y"))
}
item.set_num("x", 0.0)
item.set_num("y", 0.0)
item.set_num("width", w)
item.set_num("height", h)
ignore(item.bounds.set(0.0, 0.0, w, h))
ignore(@scene.bound_stroke(item.bounds, item))
ignore(l.mark.bounds.clear().union(item.bounds))
}
///|
/// Place a group of legends sharing an orient next to the plot
/// (`legendParams` + `gridLayout`).
fn place_legends(
group : Array[Legend],
orient : String,
xb : @scene.Bounds,
yb : @scene.Bounds,
width : Double,
height : Double,
) -> Unit {
let offset = 18.0
let vertical = orient == "left" || orient == "right"
let opt : GridOpt = {
align_col: "each",
align_row: "each",
pad_col: 8.0,
pad_row: 8.0,
columns: if vertical {
1
} else {
group.length()
},
center_col: false,
center_row: false,
flush: true,
anchor_x: 0.0,
anchor_y: 0.0,
anchor_col: "",
anchor_row: "start",
}
let o = match orient {
"left" =>
{
..opt,
anchor_x: xb.x1.floor() - offset,
anchor_col: "end",
anchor_y: 0.0 *
(if height != 0.0 { height } else { xb.height() + 2.0 * xb.y1 }),
anchor_row: "start",
}
"right" =>
{
..opt,
anchor_x: ceil(xb.x2) + offset,
anchor_y: 0.0 *
(if height != 0.0 { height } else { xb.height() + 2.0 * xb.y1 }),
anchor_row: "start",
}
"top" =>
{
..opt,
anchor_y: yb.y1.floor() - offset,
anchor_row: "end",
anchor_x: 0.0 *
(if width != 0.0 { width } else { yb.width() + 2.0 * yb.x1 }),
anchor_col: "start",
}
_ =>
{
..opt,
anchor_y: ceil(yb.y2) + offset,
anchor_x: 0.0 *
(if width != 0.0 { width } else { yb.width() + 2.0 * yb.x1 }),
anchor_col: "start",
}
}
ignore(grid_layout(group.map(legend_item), group.map(fn(l) { l.mark }), o))
}
// ---- gradient legends (continuous colour scales) ----
///|
/// A vertical gradient legend: a colour ramp with tick labels
/// (vega-parser `legend-gradient.js` and `legend-gradient-labels.js`).
pub fn build_gradient_legend(
title : String?,
seq : @scale.Sequential,
length : Double,
ramp_opacity : Double,
label_format : String?,
) -> Legend raise CompileError {
let thickness = 16.0
let d = seq.scale.domain
let lo = d[0]
let hi = d[d.length() - 1]
let frac = @scale.Continuous::new(Linear)
frac.domain = [lo, hi]
let fraction = fn(v : Double) -> Double {
match frac.map(v) {
Some(x) => x
None => @double.not_a_number
}
}
// the colour ramp
let stop_values = seq.scale.ticks(count=15.0)
if lo != stop_values[0] {
stop_values.insert(0, lo)
}
if hi != stop_values[stop_values.length() - 1] {
stop_values.push(hi)
}
let stops : Array[@value.Value] = []
for v in stop_values {
stops.push(
Obj(
Map::from_array([
("offset", Num(fraction(v))),
("color", Str(seq.map(v).unwrap_or(""))),
]),
),
)
}
let ramp = mk_item([
("x", num(0.0)),
("y", num(0.0)),
("width", num(thickness)),
("height", num(length)),
("stroke", str("#ddd")),
("strokeWidth", num(0.0)),
(
"fill",
Obj(
Map::from_array([
("gradient", Str("linear")),
("x1", Num(0.0)),
("y1", Num(1.0)),
("x2", Num(0.0)),
("y2", Num(0.0)),
("stops", Arr(stops)),
]),
),
),
])
ramp.set_num("opacity", ramp_opacity)
// labels at the ticks
let count = max(2.0, 2.0 * (length / 100.0).floor())
let mut values = seq.scale.ticks(count~)
if values.length() < 3 && lo != hi {
values = [lo, hi]
}
let fmt = @format.format_span(lo, hi, count, specifier?=label_format) catch {
_ => raise CompileError("legend.format 无效")
}
let label_items = values.map(fn(v) {
let perc = fraction(v)
let it = text_item(
[
("opacity", num(1.0)),
("text", str(fmt(v))),
("align", str("left")),
(
"baseline",
str(
if perc <= 0.0 {
"bottom"
} else if perc >= 1.0 {
"top"
} else {
"middle"
},
),
),
],
"guide-label",
)
it.set_num("y", (1.0 - perc) * length)
it.set_num("x", thickness + 2.0)
it
})
let ramp_mark = @scene.Mark::new(
Rect,
role="legend-gradient",
interactive=false,
items=[ramp],
)
let label_mark = @scene.Mark::new(
Text,
role="legend-label",
interactive=false,
items=label_items,
)
let entry_group = mk_item([("x", num(0.0)), ("y", num(0.0))])
entry_group.children.push(ramp_mark)
entry_group.children.push(label_mark)
let entry_mark = @scene.Mark::new(
Group,
role="legend-entry",
interactive=false,
items=[entry_group],
)
let item = mk_item([
("orient", str("right")),
("padding", num(0.0)),
("titlePadding", num(5.0)),
])
item.children.push(entry_mark)
let title_mark = match title {
Some(t) => {
let it = text_item(
[
("opacity", num(1.0)),
("orient", str("top")),
("text", str(t)),
("limit", num(180.0)),
("x", num(0.0)),
("y", num(0.0)),
("anchor", str("start")),
("angle", num(0.0)),
("align", str("left")),
("baseline", str("top")),
],
"guide-title",
)
let m = @scene.Mark::new(Text, role="legend-title", interactive=false, items=[
it,
])
item.children.push(m)
Some(m)
}
None => None
}
let mark = @scene.Mark::new(Group, role="legend", interactive=false, items=[
item,
])
try {
@scene.bound_mark(ramp_mark)
@scene.bound_mark(label_mark)
remove_overlap(label_mark, "parity", 0.0)
ignore(entry_group.bounds.clear())
ignore(@scene.bound_group(entry_group.bounds, entry_group))
ignore(entry_mark.bounds.clear().union(entry_group.bounds))
match title_mark {
Some(m) => @scene.bound_mark(m)
None => ()
}
ignore(item.bounds.clear())
ignore(@scene.bound_group(item.bounds, item))
ignore(mark.bounds.clear().union(item.bounds))
} catch {
@scene.SceneError(m) => raise CompileError(m)
}
{
mark,
orient: "right",
entry_items: [],
scope_mark: @scene.Mark::new(Group, role="scope"),
entry_mark,
title_mark,
}
}