empowered.house ↗

charts/gauge.ex

Gauge

A semicircular gauge whose arc sweeps to its value, with a target tick and an optional warning zone.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.charts_gauge animated />
</div>
default
isometric
inside the warning zone
no target, no zone · hover · no glow
light surface · custom scale
isometric · fade out · still

Props

Prop Type Default What it does
title string "Warehouse capacity in use"
caption string "Today"
value any 72 A number between 0 and `max`.
max any 100 The right end of the scale. The left end is 0.
unit string "%" Written right after the value and the target.
target any 85 A number on the scale, drawn as a tick across the arc. `nil` hides it.
zone any ~c"Zd" The warning zone as `[from, to]` on the scale. A value inside it turns the arc coral. `nil` hides it.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/charts/gauge.ex
gauge.ex
defmodule EHVisuals.Charts.Gauge do
  @moduledoc """
  A semicircular gauge: the arc sweeps to the value, a tick marks the target
  and a thin outer arc marks the warning zone.
  """

  use EHVisuals.Composition,
    name: "charts/gauge",
    title: "Gauge",
    description:
      "A semicircular gauge whose arc sweeps to its value, with a target tick and an optional warning zone."

  # The dial: centre of the half circle and the radius of the middle of the arc.
  @cx 160
  @cy 170
  @r 92

  attr(:title, :string, default: "Warehouse capacity in use")
  attr(:caption, :string, default: "Today")
  attr(:value, :any, default: 72, doc: "A number between 0 and `max`.")
  attr(:max, :any, default: 100, doc: "The right end of the scale. The left end is 0.")
  attr(:unit, :string, default: "%", doc: "Written right after the value and the target.")

  attr(:target, :any,
    default: 85,
    doc: "A number on the scale, drawn as a tick across the arc. `nil` hides it."
  )

  attr(:zone, :any,
    default: [90, 100],
    doc:
      "The warning zone as `[from, to]` on the scale. A value inside it turns the arc coral. `nil` hides it."
  )

  attr(:label, :string,
    default: "A gauge at 72 percent, under its target of 85 and clear of the warning zone"
  )

  frame_attrs()

  @spec charts_gauge(map()) :: Phoenix.LiveView.Rendered.t()
  def charts_gauge(assigns) do
    top = if is_number(assigns.max) and assigns.max > 0, do: assigns.max, else: 100
    share = fn number -> number |> max(0) |> min(top) |> Kernel./(top) end
    value = if is_number(assigns.value), do: assigns.value, else: 0

    zone =
      case assigns.zone do
        [from, to] when is_number(from) and is_number(to) and to > from -> {from, to}
        _other -> nil
      end

    assigns =
      assigns
      |> assign(:track, arc(0, 1, @r))
      |> assign(:sweep, if(share.(value) > 0, do: arc(0, share.(value), @r)))
      |> assign(:tip, point(share.(value), @r))
      |> assign(:warn, zone != nil and value >= elem(zone, 0) and value <= elem(zone, 1))
      |> assign(:zone_arc, zone && arc(share.(elem(zone, 0)), share.(elem(zone, 1)), @r + 11))
      |> assign(
        :tick,
        is_number(assigns.target) &&
          {point(share.(assigns.target), @r - 10), point(share.(assigns.target), @r + 10)}
      )
      |> assign(
        :marks,
        for(part <- [0.25, 0.5, 0.75], do: {point(part, @r - 12), point(part, @r - 17)})
      )
      |> assign(:shown, "#{number(value)}#{assigns.unit}")
      |> assign(
        :goal,
        is_number(assigns.target) && "Target #{number(assigns.target)}#{assigns.unit}"
      )
      |> assign(:ends, {number(0), number(top)})
      |> assign(:cx, @cx)
      |> assign(:cy, @cy)
      |> assign(:r, @r)

    ~H"""
    <.frame name="charts/gauge" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 320 208">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="319" height="207" rx="12" />
          <text class="ehv-t-2 ehv-a-fade ehv-i-1" x="20" y="34" font-size="12">{@title}</text>
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="300" y="34" font-size="9" text-anchor="end">
            {String.upcase(@caption)}
          </text>
          <path class="ehv-charts-gauge__track ehv-a-fade ehv-i-2" d={@track} />
          <g class="ehv-a-fade ehv-i-2">
            <line
              :for={{{x1, y1}, {x2, y2}} <- @marks}
              class="ehv-line"
              x1={x1}
              y1={y1}
              x2={x2}
              y2={y2}
            />
          </g>
          <path
            :if={@zone_arc}
            class="ehv-charts-gauge__zone ehv-a-fade ehv-i-3"
            d={@zone_arc}
          />
          <path
            :if={@sweep}
            class={[
              "ehv-charts-gauge__value ehv-a-draw ehv-i-3",
              @warn && "ehv-charts-gauge__value--warn"
            ]}
            d={@sweep}
            pathLength="1"
          />
          <circle
            :if={@sweep}
            class="ehv-fill-canvas ehv-a-pop ehv-i-10"
            cx={elem(@tip, 0)}
            cy={elem(@tip, 1)}
            r="2.5"
          />
          <line
            :if={@tick}
            class="ehv-charts-gauge__target ehv-a-fade ehv-i-6"
            x1={@tick |> elem(0) |> elem(0)}
            y1={@tick |> elem(0) |> elem(1)}
            x2={@tick |> elem(1) |> elem(0)}
            y2={@tick |> elem(1) |> elem(1)}
          />
          <text
            class={["ehv-t-display ehv-a-fade ehv-i-4", @warn && "ehv-t-coral"]}
            x={@cx}
            y={@cy - 22}
            font-size="32"
            text-anchor="middle"
          >
            {@shown}
          </text>
          <text
            :if={@goal}
            class="ehv-mono ehv-t-2 ehv-a-fade ehv-i-6"
            x={@cx}
            y={@cy - 3}
            font-size="8.5"
            text-anchor="middle"
          >
            {String.upcase(@goal)}
          </text>
          <g class="ehv-a-fade ehv-i-2">
            <text class="ehv-mono" x={@cx - @r} y={@cy + 22} font-size="8" text-anchor="middle">
              {elem(@ends, 0)}
            </text>
            <text class="ehv-mono" x={@cx + @r} y={@cy + 22} font-size="8" text-anchor="middle">
              {elem(@ends, 1)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # An arc of the dial between two shares of the scale, 0 at the left end and
  # 1 at the right end, drawn over the top.
  defp arc(from, to, radius) do
    {x1, y1} = point(from, radius)
    {x2, y2} = point(to, radius)
    "M#{x1} #{y1} A#{radius} #{radius} 0 0 1 #{x2} #{y2}"
  end

  defp point(share, radius) do
    angle = :math.pi() * (1 - share)

    {Float.round(@cx + radius * :math.cos(angle), 2),
     Float.round(@cy - radius * :math.sin(angle), 2)}
  end

  defp number(value) when is_float(value) do
    if value == Float.round(value), do: Integer.to_string(round(value)), else: "#{value}"
  end

  defp number(value), do: "#{value}"
end
assets/css/charts/gauge.css
gauge.css
/* charts/gauge */
.ehv-charts-gauge { --ehv-max-w: 350px; }

.ehv-charts-gauge__track {
  fill: none;
  stroke: var(--ehv-border-soft);
  stroke-width: 12;
  stroke-linecap: round;
}
.ehv-charts-gauge__value {
  fill: none;
  stroke: var(--ehv-lime-fill);
  stroke-width: 12;
  stroke-linecap: round;
}
.ehv-charts-gauge__value--warn { stroke: var(--ehv-coral); }
.ehv-charts-gauge__zone { fill: none; stroke: var(--ehv-coral); stroke-width: 2; stroke-linecap: round; }
.ehv-charts-gauge__target { fill: none; stroke: var(--ehv-ink); stroke-width: 1.5; stroke-linecap: round; }

.ehv--hover .ehv-charts-gauge__value { transition: stroke-width 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-charts-gauge__value { stroke-width: 15; }