Skip to content

geofflane/vectored

Vectored

Hex.pm Hex Docs Tests

Vectored is a lightweight, extensible Elixir library for generating SVG images programmatically. It leverages Erlang's built-in :xmerl for XML generation, ensuring no heavy external dependencies while providing a fluent, Elixir-native API.

Pros and Cons

Pros

  • Zero Runtime Dependencies: Only depends on Elixir and Erlang/OTP (using built-in :xmerl).
  • Fluent API: Easily pipe attribute setters (with_fill, with_stroke, etc.) to build complex elements.
  • Extensible: Use the defelement macro to create your own SVG elements or implement the Vectored.Renderable protocol.
  • Accessibility & Interop: Built-in support for <title>/<desc> and data-* attributes for frontend framework integration.

Cons

  • Low-Level: This library does not provide high-level abstractions (like "BarChart" or "Icon"). It is a thin wrapper over the SVG specification.
  • Requires SVG Knowledge: You need to understand how SVG coordinates, viewboxes, and element nesting work.
  • No Validation: The library doesn't prevent you from setting invalid attribute values (e.g., with_fill("not-a-color")) or nesting elements incorrectly according to the SVG spec.
  • Verbosity: Building complex scenes involves a lot of Elixir code compared to writing raw XML or using a templating engine.

Installation

Add it to your mix.exs:

def deps do
  [
    {:vectored, "~> 0.4.0"}
  ]
end

Quick Start

alias Vectored.Elements.{Svg, Circle, Rectangle, Stop, LinearGradient}

# Create a simple SVG with a gradient-filled circle
{:ok, svg_string} =
  Vectored.new()
  |> Svg.with_size(200, 200)
  |> Svg.append_defs(
    LinearGradient.new([
      Stop.new("0%", "red"),
      Stop.new("100%", "blue")
    ]) |> LinearGradient.with_id("my_grad")
  )
  |> Svg.append(
    Circle.new(100, 100, 80)
    |> Circle.with_fill("url(#my_grad)")
  )
  |> Vectored.to_svg_string()

Advanced Examples

Complex Paths

Vectored provides a dedicated Path DSL for building complex shapes:

alias Vectored
alias Vectored.Elements.{Path, Svg}

{:ok, svg} =
    Vectored.new()
    |> Svg.with_view_box(100, 100)
    |> Svg.append(fn ->
        Path.new()
        |> Path.move_to(10, 30)
        |> Path.eliptical_arc_curve(20, 20, 0, 0, 1, 50, 30)
        |> Path.eliptical_arc_curve(20, 20, 0, 0, 1, 90, 30)
        |> Path.quadratic_bezier_curve(90, 60, 50, 90)
        |> Path.quadratic_bezier_curve(10, 60, 10, 30)
        |> Path.close_path()
    end)
    |> Vectored.to_svg_string()

Creates an SVG image:

Example generated SVG

Programmatic Example

defmodule FieldDiagram do
  alias Vectored
  alias Vectored.Elements.{Circle, Defs, Group, Line, Marker, Path, Polyline, Svg, Use}

  @width 160
  @height 300
  @image_los_offset 12
  @hash_offset 70.75
  @hash_width 2
  @hash_stroke 0.5

  @doc """
  Generate an SVG that is a slice of the field to show motions
  """
  @spec generate_svg(number(), number()) :: {:ok, String.t()} | {:error, term()}
  def generate_svg(width, height) do

    Vectored.new()
    |> Svg.with_view_box(width, height)
    |> Svg.with_style("background-color: #eee")
    |> with_field()
    |> Vectored.to_svg_string()
  end

  # This builds the hashmarks and whatnot
  defp with_field(svg) do
    Svg.append(svg, fn ->
     Group.new()
      |> Group.with_id("field")
      |> Group.append(yard_markers())
      |> Group.append(los())
    end)
  end

  defp los() do
    Line.new()
    |> Line.from(0, @image_los_offset)
    |> Line.to(@width, @image_los_offset)
    |> Line.with_stroke("yellow")
    |> Line.with_stroke_width(1)
  end

  def yard_markers() do
    Enum.flat_map(0..@height//3, fn y ->
      if rem(y, 15) == 0 do
        Line.new()
        |> Line.from(0, y)
        |> Line.to(@width, y)
        |> Line.with_stroke("white")
        |> Line.with_stroke_width(1)
        |> List.wrap()
      else
        # Hash marks
        h1 =
          Line.new()
          |> Line.from(@hash_offset, y)
          |> Line.to(@hash_offset + @hash_width, y)
          |> Line.with_stroke("white")
          |> Line.with_stroke_width(@hash_stroke)

        h2 =
          Line.new()
          |> Line.from(@width - @hash_offset, y)
          |> Line.to(@width - @hash_offset - @hash_width, y)
          |> Line.with_stroke("white")
          |> Line.with_stroke_width(@hash_stroke)

        [h1, h2]
      end
    end)
  end
end

with {:ok, svg} <- FieldDiagram.generate_svg(160, 60) do
  File.write!("field.svg", svg)
end

Generated Field SVG

Seeing it in Action (Kitchen Sink)

To see a comprehensive demonstration of all supported SVG elements and attributes, you can run the integration "Kitchen Sink" test. This will generate a kitchen_sink.svg file in your project root.

mix test test/vectored/integration/kitchen_sink_test.exs --include manual

This image serves as a visual specification of the library's capabilities, including gradients, masks, symbols, and text styling.

Features & Supported Elements

  • Shapes: Circle, Rectangle, Ellipse, Line, Polyline, Polygon.
  • Text: Text, Tspan.
  • Structure: Group (g), Defs, Use, Symbol, Marker.
  • Composition: ClipPath, Mask.
  • Aesthetics: LinearGradient, RadialGradient, Pattern, Image.

TODO

  • Validate attributes (types, required, etc)
  • More SVG structures
  • More extensive tests (test xml output with xpath?)

Documentation

Full documentation is available on HexDocs.

Copyright and License

Copyright (c) 2024, Geoff Lane.

Source code is licensed under the MIT License.

About

Elixir SVG primitives and rendering

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages