legend_gradient-vertical.js

/**
 * @function legend/gradient_vertical
 * @description The `legend.gradient_vertical` function adds a vertical gradient legend to an SVG container. It draws a series of colored rectangles with three labels aligned at top, middle, and bottom. Returns the legend layer or renders directly if no container is provided.
 * @see {@link https://observablehq.com/@neocartocnrs/legends}
 *
 * @property {string} [id] - unique id for the legend
 * @property {number[]} [pos = [0,0]] - position of the legend in SVG
 * @property {number} [gap = 5] - gap between title/subtitle and rectangles
 * @property {string[]} [colors = ["#fee5d9", "#fcae91", "#fb6a4a", "#cb181d"]] - colors of the gradient
 * @property {number} [rect_width = 8] - rectangle width
 * @property {number} [rect_height = 25] - rectangle height
 * @property {number} [rect_spacing = 0] - spacing between rectangles
 * @property {string} [rect_stroke = "white"] - stroke color of rectangles
 * @property {number} [values_fontSize = 12] - font size of labels
 * @property {string} [values_fill = "black"] - fill color of labels
 * @property {string} [text_high = "High"] - label at top
 * @property {string} [text_intermediate = "Intermediate"] - label at middle
 * @property {string} [text_low = "Low"] - label at bottom
 * @property {boolean} [reverse = false] - reverse the order of colors
 * @property {boolean} [frame = false] - whether to draw a frame around legend
 * @example
 * geoviz.legend.gradient_vertical(svg, { pos: [10,20], colors, text_high:"Strong", text_low:"Weak" });
 * geoviz.legend.gradient_vertical({ pos: [10,20], colors, text_high:"High", text_low:"Low" }); // no container
 */
import { create } from "../container/create";
import { render } from "../container/render";
import { camelcasetodash } from "../helpers/camelcase";
import { getsize } from "../helpers/getsize";
import {
  addTitle,
  addSubtitle,
  addNote,
  subsetobj,
  addFrame,
  manageoptions,
} from "../helpers/utils_legend.js";
import { formatLocale } from "d3-format";

const d3 = Object.assign({}, { formatLocale });

export function gradient_vertical(arg1, arg2) {
  // Determine if we need to create a new SVG container
  const newcontainer =
    (arguments.length <= 1 || arg2 === undefined) && !arg1?._groups;

  arg1 = newcontainer && arg1 === undefined ? {} : arg1;
  arg2 = arg2 === undefined ? {} : arg2;
  const svg = newcontainer ? create() : arg1;

  // Default options
  const options = {
    colors: ["#fee5d9", "#fcae91", "#fb6a4a", "#cb181d"],
    rect_height: 25,
    rect_width: 8,
    rect_stroke: "white",
    rect_spacing: 0,
    rect_dx: 0,
    rect_dy: 0,
    pos: [0, 0],
    gap: 5,
    values_fontSize: 12,
    reverse: false,
    frame: false,
    text_high: "High",
    text_intermediate: "Intermediate",
    text_low: "Low",
  };

  // Merge user options
  const opts = manageoptions(
    options,
    newcontainer ? arg1 : arg2,
    svg.fontFamily,
  );

  // Initialize legend layer
  let layer = svg.selectAll(`#${opts.id}`).empty()
    ? svg
        .append("g")
        .attr("id", opts.id)
        .attr("class", "geovizlegend")
        .attr("data-layer", "legend")
    : svg.select(`#${opts.id}`);
  layer.selectAll("*").remove();

  // Add title and subtitle
  addTitle(layer, opts);
  addSubtitle(layer, opts);

  // Compute size and positions
  const size = getsize(layer);
  const posx = opts.pos[0] + opts.rect_dx;
  const posy = opts.pos[1] + size.height + opts.gap + opts.rect_dy;

  // Draw rectangle group
  const rectGroup = layer.append("g");
  const opts_rect = subsetobj(opts, {
    prefix: "rect_",
    exclude: ["fill", "width", "height", "spacing"],
  });
  Object.entries(opts_rect).forEach((d) =>
    rectGroup.attr(camelcasetodash(d[0]), d[1]),
  );

  const colors = opts.reverse ? opts.colors : opts.colors.slice().reverse();
  rectGroup
    .selectAll("rect")
    .data(colors)
    .join("rect")
    .attr("x", posx)
    .attr("y", (d, i) => posy + i * (opts.rect_height + opts.rect_spacing))
    .attr("width", opts.rect_width)
    .attr("height", opts.rect_height)
    .attr("fill", (d) => d)
    .attr("stroke", opts.rect_stroke);

  // Draw labels: top, middle, bottom
  const labels = [opts.text_high, opts.text_intermediate, opts.text_low];
  const n = opts.colors.length;
  const totalHeight = n * opts.rect_height + (n - 1) * opts.rect_spacing;

  const values = layer.append("g");
  const opts_values = Object.assign(
    subsetobj(opts, { prefix: "values_" }),
    subsetobj(opts, { prefix: "text_" }),
  );
  Object.entries(opts_values).forEach((d) =>
    values.attr(camelcasetodash(d[0]), d[1]),
  );
  values
    .selectAll("text")
    .data(labels)
    .join("text")
    .attr("x", posx + opts.rect_width + 5)
    .attr("y", (d, i) => {
      if (i === 0) return posy + opts.rect_height / 2; // top
      if (i === 1) return posy + totalHeight / 2; // middle
      if (i === 2) return posy + totalHeight - opts.rect_height / 2; // bottom
    })
    .text((d) => d)
    // .attr("font-size", opts.values_fontSize)
    // .attr("fill", opts.values_fill)
    .attr("dominant-baseline", "middle");

  // Add note if any
  addNote(layer, opts);

  // Add frame if requested
  if (opts.frame) addFrame(layer, opts);

  // Output: render or return layer ID
  if (newcontainer) {
    const newSize = getsize(layer);
    svg
      .attr("width", newSize.width)
      .attr("height", newSize.height)
      .attr("viewBox", [newSize.x, newSize.y, newSize.width, newSize.height]);
    return render(svg);
  } else {
    return `#${opts.id}`;
  }
}