Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Arguments

Argument Description Example Default
Plots Plots as JSON See #Example
From Lowest x,y to plot -pi/2,0 0,0
To Highest x,y to plot 10^2,2
Origin X/Y Axis origin 0.5,0.5 0,0
GridStep Coordinate grid step size pi/2,1 1,1
Inline Display the plot inline instead of floating true (absent)
LabelX, LabelY Axis label (not implemented)
LabelStepX Label for grid X-axis increments format("%sπ", dfrac(x/pi, 1)) format("%.0f", x)
LabelStepY Label for grid X-axis increments format("%.1f", y) format("%.0f", y)
ColorX X-axis color #44f #f44
ColorY Y-axis color #0f4 #0f0

Underlined Arguments are required.

Available Functions and Constants in Expressions

  • printf(format, ...args)
    See Lua string.format.
  • dfrac(number, is_factor)
    Formats a float as a fraction, if possible. If it is a factor, then the number 1 or -1 will only produce its sign as an output, useful when formatting as a factor of pi, for example. See #Example below.
  • sin,floor,abs,...
    Any constant or function from the Lua math library can be used directly (without the math. prefix).

Example

{{#invoke:Plotter|Plot2D
|From=-0.2, -1.2
|To=2 * pi + 0.2, 1.2
|GridStep=0.5 * pi, 1
|LabelStepX=format("%sπ", dfrac(x/pi, 1))
|Plots=
[
{"Type": "function", "Function": "sin(x)", "Samples": 100, "Label": "sin"},
{"Type": "function", "Function": "cos(x)", "Samples": 100, "Label": "cos"},
{"Type": "function", "Function": "tan(x)", "Samples": 35, "Label": "tan", "Ranges": [
  {"To": "(0.5-0.2)*pi"},
  {"From": "(0.5+0.2)*pi", "To": "(1.5-0.2)*pi"},
  {"From": "(1.5+0.2)*pi"}
]}
]
}}
½π π ³⁄₂π -1 1
sin
cos
tan

---This module can generate 2D plots of functions using SVG
local p = {}

---@module "Expression"
local Expression = require("Module:Expression")

---@alias Space2D {x: number, y: number, w: number, h: number}
---@alias Vec2D {x: number, y: number}

---@alias Plot2DOptions {plots: Plot2DItemOptions[], from: Vec2D, to: Vec2D, origin: Vec2D, grid_step: Vec2D, axis_labels: AxisLabels, axis_colors: AxisColors, plot_space: Space2D, content_space: Space2D}
---@alias AxisLabels {x: string, x_step: Expr, y: string, y_step: Expr}
---@alias AxisColors {x: string, y: string}
---@alias Plot2DItemOptions {Type?: string, Color?: string|number, Label?: string}
---@alias FunctionPlotOptions {Type: `function`, Color?: string|number, Label?: string, Function?: string, Samples?: number|string, Ranges?: FunctionPlotRange[]}
---@alias FunctionPlotRange {From?: string, To?: string, Samples?: number|string}

function p.Plot2D(frame)
  assert(frame.args.Plots, "Missing 'Plots'")
  assert(frame.args.To, "Missing 'To'")

  ---@type Plot2DOptions
  local options = {
    plots = mw.text.jsonDecode(frame.args.Plots),
    from = ParseXY(frame.args.From or "0, 0"),
    to = ParseXY(frame.args.To),
    origin = ParseXY(frame.args.Origin or "0, 0"),
    grid_step = ParseXY(frame.args.GridStep or "1, 1"),
    inline = frame.args.Inline == "true",
    axis_labels = {
      x = frame.args.LabelX or "x",
      x_step = ParseExpr(frame.args.LabelStepX or 'format("%.0f", x)'),
      y = frame.args.LabelY or "y",
      y_step = ParseExpr(frame.args.LabelStepY or 'format("%.0f", y)'),
    },
    axis_colors = {
      x = frame.args.ColorX or "#f44",
      y = frame.args.ColorY or "#0f0",
    },
  }

  if options.grid_step.x <= 0 then error("Invalid x step size") end
  if options.grid_step.y <= 0 then error("Invalid y step size") end

  options.plot_space = {
    x = options.from.x,
    y = options.from.y,
    w = options.to.x - options.from.x,
    h = options.to.y - options.from.y,
  }
  options.content_space = {
    x = 0,
    y = 0,
    w = 1000,
    h = 1000 * options.plot_space.h / options.plot_space.w
  }

  -- make space for axis labels
  local label_size = { w = 60, h = 40 }
  local tx, ty, sx, sy = CreateTransform(options.plot_space, options.content_space)
  TranslateSpace(
    options.content_space,
    math.max(0, label_size.w - tx(options.origin.x)),
    0
  )
  local x_axis_label_margin = math.max(0, label_size.h - ty(options.origin.y))
  tx, ty, sx, sy = CreateTransform(options.plot_space, options.content_space)

  local svg = mw.html.create()

  local abs_origin = TransformVec2D(options.origin, tx, ty)
  local abs_step = ScaleVec2D(options.grid_step, sx, sy)

  DrawGrid(svg, options.content_space, abs_step, abs_origin)
  local axes = DrawAxes(svg, options.content_space, abs_origin, options.axis_colors)

  ---@alias Legend {color: string, label: string}[]
  ---@type Legend
  local legend = {}
  for _, plot in ipairs(options.plots) do
    DrawPlot(svg, options, plot, legend)
  end

  DrawAxisLabels(
    svg, axes, options.plot_space,
    options.content_space, abs_step, abs_origin,
    options.axis_labels, options.axis_colors
  )

  local fig = mw.html.create("div")
      :cssText(string.format(
        "margin: var(--space-xs) auto var(--space-md) auto;%s",
        options.inline and "" or
        " margin-left: var(--space-lg); float: right; clear: right; max-width: 500px; width: 100%;"
      ))
      :wikitext(frame:callParserFunction("#tag:svg", {
        tostring(svg),
        viewBox = string.format(
          "0 0 %s %s",
          options.content_space.w + options.content_space.x,
          options.content_space.h + x_axis_label_margin
        ),
        style = "width: 100%; fill: none; stroke-linecap: butt;",
      }))

  CreateLegend(fig, legend)

  return tostring(fig)
end

---@param str string
---@return Vec2D
function ParseXY(str)
  local i = str:find(",", 1, false)
  if i == nil then error("Invalid xy: " .. str) end
  local x = str:sub(1, i - 1)
  local y = str:sub(i + 1)
  return { x = tonumber(EvalExpr(x)), y = tonumber(EvalExpr(y)) }
end

---@param vec Vec2D
---@param tx fun(x: number): number
---@param ty? fun(y: number): number
---@return Vec2D
function TransformVec2D(vec, tx, ty)
  if ty == nil then ty = tx end
  return { x = tx(vec.x), y = ty(vec.y) }
end

---@param vec Vec2D
---@param sx number
---@param sy? number
---@return Vec2D
function ScaleVec2D(vec, sx, sy)
  if sy == nil then sy = sx end
  return { x = vec.x * sx, y = vec.y * sy }
end

---Creates a transform from one space to another
---@param from Space2D
---@param to Space2D
---@return fun(x: number): number tx Transform X
---@return fun(y: number): number ty Transform Y
---@return number sx Scale X
---@return number xy Scale Y
function CreateTransform(from, to)
  --     (x-from.x) * (to.w/from.w) + to.x
  -- <=> x * (to.w/from.w) - from.x * (to.w/from.w) + to.x
  --         |----sx-----| |-------------tx--------------|
  local sx, sy = to.w / from.w, to.h / from.h
  local tx = to.x - from.x * sx
  local ty = to.y - from.y * sy
  local function transform_x(x) return x * sx + tx end
  local function transform_y(y) return y * sy + ty end
  return transform_x, transform_y, sx, sy
end

---Moves a space around
---@param space Space2D
---@param x number
---@param y number
function TranslateSpace(space, x, y)
  space.x = space.x + x
  space.y = space.y + y
  --space.w = space.w + x
  --space.h = space.h + y
end

---Gets the edge bounds of a space
---@param space Space2D
---@return number min_x
---@return number min_y
---@return number max_x
---@return number max_y
function SpaceBounds(space)
  return space.x, space.y, space.w + space.x, space.h + space.y
end

---Creates a legend below the plot
---@param container any
---@param legend Legend
---@return any
function CreateLegend(container, legend)
  local foot = container:tag("div")
      :cssText(
        "font-size: 0.8125rem; text-align: center; color: var(--color-base--subtle); "
        .. "margin-top: var(--space-xs); padding-inline: var(--border-radius--small);"
      )
  for _, item in ipairs(legend) do
    foot
        :tag("div")
        :cssText(
          "display: inline-block; height: 0.9em; aspect-ratio: 3/2; margin-inline: 0.3em; vertical-align: middle; "
          .. "background-color: " .. item.color .. ";"
        )
        :done()
        :wikitext(item.label)
  end
  return legend
end

-- Drawing

---Draws a grid in a space
---@param svg any
---@param space Space2D
---@param step? Vec2D Grid steps, default: (1,1)
---@param origin? Vec2D Alignment point in the space, default: (0,0)
---@return any path
function DrawGrid(svg, space, step, origin)
  step = step or { x = 1, y = 1 }
  local step_x, step_y = step.x, step.y
  local min_x, min_y, max_x, max_y = SpaceBounds(space)
  origin = origin or { x = 0, y = 0 }
  local origin_x, origin_y = origin.x, max_y - origin.y

  local path = ""
  local k

  -- +x
  k = origin_x + step_x
  while (k < max_x) do
    path = path .. "M" .. k .. " " .. min_y .. "V" .. max_y
    k = k + step_x
  end

  -- -x
  k = origin_x - step_x
  while (k > min_x) do
    path = path .. "M" .. k .. " " .. min_y .. "V" .. max_y
    k = k - step_x
  end

  -- +y
  k = origin_y + step_y
  while (k < max_y) do
    path = path .. "M" .. min_x .. " " .. k .. "H" .. max_x
    k = k + step_y
  end

  -- -y
  k = origin_y - step_y
  while (k > min_y) do
    path = path .. "M" .. min_x .. " " .. k .. "H" .. max_x
    k = k - step_y
  end

  return svg:tag("path")
      :attr("stroke", "#666")
      :attr("stroke-width", 2)
      :attr("d", path)
end

---Draws axis arrows in a space
---@param svg any
---@param space Space2D
---@param origin Vec2D
---@param colors AxisColors
---@return unknown
function DrawAxes(svg, space, origin, colors)
  local min_x, min_y, max_x, max_y = SpaceBounds(space)
  origin = origin or { x = 0, y = 0 }
  local origin_x, origin_y = origin.x, max_y - origin.y

  local axes = svg:tag("g")
      :attr("stroke-width", 3)
  local axis_x = axes:tag("g")
      :attr("stroke", colors.x)
  local axis_y = axes:tag("g")
      :attr("stroke", colors.y)

  DrawArrow(axis_x, origin_x, origin_y, max_x - 5, origin_y)
  DrawArrow(axis_y, origin_x, origin_y, origin_x, min_y + 5)

  if min_x < origin_x then
    DrawLine(axis_x, origin_x - 10, origin_y, min_x, origin_y)
        :attr("stroke-dasharray", "10 10")
  end
  if max_y > origin_y then
    DrawLine(axis_y, origin_x, origin_y + 10, origin_x, max_y)
        :attr("stroke-dasharray", "10 10")
  end
  DrawCircle(svg, 4, origin_x, origin_y)
      :attr("fill", "#fff")

  return axes
end

---comment
---@param svg any
---@param axes any
---@param space Space2D
---@param step? Vec2D
---@param origin? Vec2D
---@param labels AxisLabels
---@param colors AxisColors
---@return unknown
function DrawAxisLabels(svg, axes, pspace, space, step, origin, labels, colors)
  local labels_g = svg:tag("g")
      :attr("stroke", "none")
      :cssText("font-size: 28px; text-shadow: 0 0 3px #000; font-weight: 600;")
  local notches_x = axes:tag("g")
      :attr("stroke", colors.x)
      :attr("stroke-width", 4)
  local labels_x = labels_g:tag("g")
      :attr("fill", colors.x)
      :attr("text-anchor", "middle")
      :cssText("dominant-baseline: hanging;")
  local notches_y = axes:tag("g")
      :attr("stroke", colors.y)
      :attr("stroke-width", 4)
  local labels_y = labels_g:tag("g")
      :attr("fill", colors.y)
      :attr("text-anchor", "end")
      :cssText("dominant-baseline: middle;")

  step = step or { x = 1, y = 1 }
  local step_x, step_y = step.x, step.y
  local min_x, min_y, max_x, max_y = SpaceBounds(space)
  origin = origin or { x = 0, y = 0 }
  local origin_x, origin_y = origin.x, max_y - origin.y

  ---Transform to plot space
  local tx, ty = CreateTransform(space, pspace)

  local notch_length = 10

  local cx = {}
  local k
  local current_axis
  function cx:lookup(name)
    if name == current_axis then return name == "x" and tx(k) or ty(max_y-k) end
    if name == "format" then return string.format end
    if name == "dfrac" then return DisplayFrac end
    return math[name] or error("Unknown variable: " .. name)
  end

  current_axis = "x"
  -- x0
  k = origin_x
  if max_y <= origin_y then
    DrawLine(notches_x, k, origin_y, k, origin_y + notch_length)
    DrawText(labels_x, labels.x_step:eval(cx), k, origin_y + notch_length + 6)
  end

  -- +x
  k = origin_x + step_x
  while (k < max_x) do
    DrawLine(notches_x, k, origin_y - notch_length, k, origin_y + notch_length)
    DrawText(labels_x, labels.x_step:eval(cx), k, origin_y + notch_length + 6)
    k = k + step_x
  end

  -- -x
  k = origin_x - step_x
  while (k > min_x) do
    DrawLine(notches_x, k, origin_y - notch_length, k, origin_y + notch_length)
    DrawText(labels_x, labels.x_step:eval(cx), k, origin_y + notch_length + 6)
    k = k - step_x
  end

  current_axis = "y"
  -- y0
  k = origin_y
  if min_x >= origin_x then
    DrawLine(notches_y, origin_x - notch_length, k, origin_x, k)
    DrawText(labels_y, labels.y_step:eval(cx), origin_x - notch_length - 6, k)
  end

  -- +y
  k = origin_y + step_y
  while (k < max_y) do
    DrawLine(notches_y, origin_x - notch_length, k, origin_x + notch_length, k)
    DrawText(labels_y, labels.y_step:eval(cx), origin_x - notch_length - 6, k)
    k = k + step_y
  end

  -- -y
  k = origin_y - step_y
  while (k > min_y) do
    DrawLine(notches_y, origin_x - notch_length, k, origin_x + notch_length, k)
    DrawText(labels_y, labels.y_step:eval(cx), origin_x - notch_length - 6, k)
    k = k - step_y
  end

  return labels_g
end

---Draws an arrow as a <path>
---@param svg any
---@param x1 number
---@param y1 number
---@param x2 number
---@param y2 number
---@return any path
function DrawArrow(svg, x1, y1, x2, y2)
  local dx, dy = x2 - x1, y2 - y1
  local len = math.sqrt(dx * dx + dy * dy)
  dx, dy = dx / len, dy / len
  local leftx, lefty = dy, -dx
  local arrow_size = 6
  return svg:tag("path")
      :attr("stroke-linejoin", "round")
      :attr("stroke-linecap", "round")
      :attr("d", string.format(
        "M%s %sL%s %sL%s %sL%s %sL%s %s",
        x1, y1,
        x2, y2,
        x2 + arrow_size * (leftx - dx), y2 + arrow_size * (lefty - dy),
        x2, y2,
        x2 + arrow_size * (-leftx - dx), y2 + arrow_size * (-lefty - dy)
      ))
end

---Draws a <circle>
---@param svg any
---@param r number
---@param x number
---@param y number
---@return any circle
function DrawCircle(svg, r, x, y)
  return svg:tag("circle")
      :attr("cx", x)
      :attr("cy", y)
      :attr("r", r)
end

---Draws a <line>
---@param svg any
---@param x1 number
---@param y1 number
---@param x2 number
---@param y2 number
---@return any line
function DrawLine(svg, x1, y1, x2, y2)
  return svg:tag("line")
      :attr("x1", x1)
      :attr("y1", y1)
      :attr("x2", x2)
      :attr("y2", y2)
end

---Draws a <text>
---@param svg any
---@param text string
---@param x number
---@param y number
---@return any text
function DrawText(svg, text, x, y)
  return svg:tag("text")
      :attr("x", x)
      :attr("y", y)
      :wikitext(text)
end

-- Plotting

---@alias Plot2DHandler fun(svg, options: Plot2DOptions, plot: Plot2DItemOptions): any

---@type table<string, Plot2DHandler>
local plotters = {}
local plot_colors = { "#ff0", "#f0f", "#0ff", "f44" }
local color_idx = 1

function GetPlotColor()
  local color = plot_colors[color_idx]
  color_idx = color_idx == #plot_colors and 1 or color_idx + 1
  return color
end

---Draws a plot
---@param svg any
---@param options any
---@param plot Plot2DItemOptions
---@param legend {color: string, label: string}[] Append legend data to this table
---@return unknown
function DrawPlot(svg, options, plot, legend)
  assert(plot.Type, "Missing plot 'Type'")
  local plotter = plotters[plot.Type]
  if not plotter then error("Unknown plot type '" .. plot.Type .. "'") end
  if not plot.Color then
    plot.Color = GetPlotColor()
  elseif type(plot.Color) == "number" then
    plot.Color = plot_colors[plot.Color]
  end
  if plot.Label then
    table.insert(legend, { color = plot.Color, label = plot.Label })
  end
  return plotter(svg, options, plot)
end

---@type Plot2DHandler
---@param plot FunctionPlotOptions
plotters["function"] = function(svg, options, plot)
  assert(plot.Function, "Function plot missing 'Function'")
  local plot_func = ParseExpr(plot.Function)
  local path = ""
  local samples = plot.Samples and tonumber(plot.Samples) or 50
  local tx, ty = CreateTransform(options.plot_space, options.content_space)

  local ranges = {}
  if plot.Ranges then
    assert(#plot.Ranges > 0, "Empty 'Ranges'")
    for _, range in ipairs(plot.Ranges) do
      local rfrom = range.From and EvalExpr(range.From) or options.from.x
      local rto = range.To and EvalExpr(range.To) or options.to.x
      assert(rfrom < rto, "Invalid range")
      local rsamples = range.Samples and tonumber(range.Samples) or samples
      table.insert(
        ranges,
        {
          from = rfrom,
          to = rto,
          samples = rsamples,
          step = (rto - rfrom) / (rsamples - 1),
        }
      )
    end
  else
    table.insert(
      ranges,
      {
        from = options.from.x,
        to = options.to.x,
        samples = samples,
        step = options.plot_space.w / (samples - 1)
      }
    )
  end

  local max_y = options.content_space.h + options.content_space.y

  local cx = {}
  local x = 0
  function cx:lookup(name)
    if name == "x" then
      return x
    else
      return math[name] or error("Unknown variable: " .. name)
    end
  end

  for _, range in ipairs(ranges) do
    local path_op = "M"
    for i = 0, range.samples - 1 do
      x = range.from + i * range.step
      local y = plot_func:eval(cx)
      path = path .. path_op .. tx(x) .. " " .. (max_y - ty(y))
      path_op = "L"
    end
  end

  return svg:tag("path")
      :attr("stroke", plot.Color)
      :attr("stroke-width", 3)
      :attr("stroke-linejoin", "bevel")
      :attr("d", path)
end

-- Expression Logic

---Parses any expression and validates the result
---@param str string raw expression
---@return Expr expression
function ParseExpr(str)
  local tok = Expression.Tokenizer:new { str = str }
  local expr = Expression.parse_expr(tok)
  if not expr then error("Invalid expression: " .. expr) end
  if tok:peek() then error("Leftover tokens: " .. tok.str) end
  return expr
end

---Evaluates a simple math expression
---@param str string raw expression
---@param vars? table<string, number> variables
---@return number
function EvalExpr(str, vars)
  local cx = {}
  function cx:lookup(name)
    return vars and vars[name] or math[name] or error("Unknown variable: " .. name)
  end

  return ParseExpr(str):eval(cx)
end

local nice_frac_table = {
  -- 0.
  ["5000"] = "½",
  ["3333"] = "⅓",
  ["6667"] = "⅔",
  ["2500"] = "¼",
  ["7500"] = "¾",
  ["2000"] = "⅕",
  ["4000"] = "⅖",
  ["6000"] = "⅗",
  ["8000"] = "⅘",
  ["1667"] = "⅙",
  ["8333"] = "⅚",
  ["1429"] = "⅐",
  ["1250"] = "⅛",
  ["3750"] = "⅜",
  ["6250"] = "⅝",
  ["8750"] = "⅞",
  ["1111"] = "⅑",
  ["1000"] = "⅒",
  -- 1.
  ["15000"] = "³⁄₂",
  ["13333"] = "⁴⁄₃",
  ["12500"] = "⁵⁄₄",
  ["12000"] = "⁶⁄₅",
  ["11667"] = "⁷⁄₆",
  ["11429"] = "⁸⁄₇",
  ["11250"] = "⁹⁄₈",
  ["11111"] = "¹⁰⁄₉",
  -- 2.
  ["25000"] = "⁵⁄₂",
}

function DisplayFrac(num, is_factor)
  is_factor = is_factor and is_factor ~= 0
  local sign = "";
  local pnum = num < 0 and -num or num
  local index = tostring(math.floor(pnum * 10000 + 0.5))
  local nice = nice_frac_table[index]

  if nice then return sign .. nice end
  if is_factor and index == "10000" then return sign end
  if index:sub(2) == "0000" then return tostring(math.floor(num)) end
  return string.format("%.2f", num)
end

return p