empowered.house ↗

search/semantic.ex

Semantic search

A query landing in an embedding space, where a radius around it lights up the nearest neighbours.

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

<div class="h-96 rounded-xl border border-border">
  <.search_semantic animated />
</div>
default
isometric
another query · tighter radius
another cloud · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
query string "late deliveries from Acme" Up to 34 characters.
x float 0.6 Where the query lands, 0 (left) to 1 (right).
y float 0.62 Where the query lands, 0 (top) to 1 (bottom).
radius float 0.24 The search radius, as a share of the plot width.
names list ["Carrier cut-off times", "Returns in March", "Acme delivery log", "Late order report"] Names for the nearest neighbours, nearest first. Up to five, 20 characters each.
count integer 46 How many dots are in the space, 8 to 80.
seed integer 7 Changes the cloud. The same seed draws the same cloud.
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/search/semantic.ex
semantic.ex
defmodule EHVisuals.Search.Semantic do
  @moduledoc """
  Semantic search: a query lands in an embedding space, drawn as a scatter of
  dots, and a radius around it lights up the nearest neighbours.

  The cloud is generated from a seed, so it is the same on every render. Which
  dots light up, their order and their scores are computed from the position
  of the query and the radius.
  """

  use EHVisuals.Composition,
    name: "search/semantic",
    title: "Semantic search",
    description:
      "A query landing in an embedding space, where a radius around it lights up the nearest neighbours."

  # The plot, inside the card.
  @plot %{x: 16, y: 54, w: 240, h: 230, pad: 14}
  @clusters [{0.26, 0.3}, {0.72, 0.24}, {0.64, 0.68}, {0.22, 0.76}]

  attr(:query, :string, default: "late deliveries from Acme", doc: "Up to 34 characters.")
  attr(:x, :float, default: 0.6, doc: "Where the query lands, 0 (left) to 1 (right).")
  attr(:y, :float, default: 0.62, doc: "Where the query lands, 0 (top) to 1 (bottom).")
  attr(:radius, :float, default: 0.24, doc: "The search radius, as a share of the plot width.")

  attr(:names, :list,
    default: [
      "Carrier cut-off times",
      "Returns in March",
      "Acme delivery log",
      "Late order report"
    ],
    doc: "Names for the nearest neighbours, nearest first. Up to five, 20 characters each."
  )

  attr(:count, :integer, default: 46, doc: "How many dots are in the space, 8 to 80.")
  attr(:seed, :integer, default: 7, doc: "Changes the cloud. The same seed draws the same cloud.")

  attr(:label, :string,
    default: "A query placed in a scatter of dots, with the dots inside its radius lit and listed"
  )

  frame_attrs()

  @spec search_semantic(map()) :: Phoenix.LiveView.Rendered.t()
  def search_semantic(assigns) do
    {qx, qy} = place(clamp(assigns.x), clamp(assigns.y))
    reach = Float.round(max(assigns.radius, 0.04) * (@plot.w - 2 * @plot.pad), 1)

    dots =
      assigns.seed
      |> cloud(assigns.count |> max(8) |> min(80))
      |> Enum.with_index()
      |> Enum.map(fn {{x, y}, index} ->
        {px, py} = place(x, y)
        distance = :math.sqrt((px - qx) ** 2 + (py - qy) ** 2)
        %{x: px, y: py, batch: rem(index, 6) + 1, distance: distance, near: distance <= reach}
      end)

    near = dots |> Enum.filter(& &1.near) |> Enum.sort_by(& &1.distance)

    hits =
      near
      |> Enum.zip(Enum.take(assigns.names, 5))
      |> Enum.with_index()
      |> Enum.map(fn {{dot, name}, index} ->
        score = Float.round(1 - dot.distance / (reach * 4), 2)

        %{
          index: index,
          y: 80 + index * 38,
          name: name,
          score: :erlang.float_to_binary(score, decimals: 2),
          bar: Float.round(84 * score, 1)
        }
      end)

    assigns =
      assigns
      |> assign(:dots, Enum.reject(dots, & &1.near))
      |> assign(:near, Enum.with_index(near))
      |> assign(:hits, hits)
      |> assign(:q, %{x: qx, y: qy, r: reach})
      |> assign(:plot, @plot)
      |> assign(:chip_w, Float.round(40 + String.length(assigns.query) * 6.3, 1))
      |> assign(:landing, "M40 42 Q40 #{qy} #{qx - 6} #{qy}")
      |> assign(:tally, "#{length(near)} of #{length(dots)} in radius")

    ~H"""
    <.frame name="search/semantic" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 400 300">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="399" height="299" rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <rect
              class="ehv-well"
              x={@plot.x + 0.5}
              y={@plot.y + 0.5}
              width={@plot.w - 1}
              height={@plot.h - 1}
              rx="9"
            />
            <line
              :for={step <- 1..3}
              class="ehv-line-faint"
              x1={@plot.x + step * 60 + 0.5}
              x2={@plot.x + step * 60 + 0.5}
              y1={@plot.y + 1}
              y2={@plot.y + @plot.h - 1}
            />
            <line
              :for={step <- 1..3}
              class="ehv-line-faint"
              x1={@plot.x + 1}
              x2={@plot.x + @plot.w - 1}
              y1={@plot.y + step * 57.5}
              y2={@plot.y + step * 57.5}
            />
            <text class="ehv-mono ehv-t-4" x={@plot.x + 10} y={@plot.y + @plot.h - 9} font-size="7">
              {String.upcase(@tally)}
            </text>
          </g>
          <g :for={batch <- 1..6} class={"ehv-a-fade ehv-i-#{batch + 1}"}>
            <circle
              :for={dot <- @dots}
              :if={dot.batch == batch}
              class="ehv-fill-4"
              cx={dot.x}
              cy={dot.y}
              r="2.2"
            />
          </g>
          <g class="ehv-a-pop ehv-i-11">
            <circle class="ehv-search-semantic__radius" cx={@q.x} cy={@q.y} r={@q.r} />
          </g>
          <line
            :for={{dot, index} <- @near}
            class={"ehv-search-semantic__tie ehv-a-draw ehv-i-#{min(index + 12, 24)}"}
            x1={@q.x}
            y1={@q.y}
            x2={dot.x}
            y2={dot.y}
            pathLength="1"
          />
          <g class="ehv-a-fade ehv-i-2">
            <circle
              :for={{dot, index} <- @near}
              class={"ehv-search-semantic__near ehv-i-#{min(index + 12, 24)}"}
              cx={dot.x}
              cy={dot.y}
              r="3"
            />
          </g>
          <path class="ehv-line-lime ehv-round ehv-a-draw ehv-i-6" d={@landing} pathLength="1" />
          <circle class="ehv-fill-lime ehv-a-ping ehv-i-12" cx={@q.x} cy={@q.y} r="6" />
          <g class="ehv-a-pop ehv-i-10">
            <circle class="ehv-node-lime" cx={@q.x} cy={@q.y} r="6" />
            <circle class="ehv-fill-lime" cx={@q.x} cy={@q.y} r="2.6" />
          </g>
          <g class="ehv-a-fade ehv-i-3">
            <rect class="ehv-card-3" x="16.5" y="14.5" width={@chip_w - 1} height="27" rx="8" />
            <circle class="ehv-line-ink" cx="31" cy="27" r="4.5" />
            <path class="ehv-line-ink ehv-round" d="M34.4 30.4 l3.4 3.4" />
            <text x="46" y="32" font-size="12">{@query}</text>
          </g>
          <text class="ehv-mono ehv-a-fade ehv-i-12" x="268" y="68" font-size="8">NEAREST</text>
          <g :for={hit <- @hits} class={"ehv-a-rise ehv-i-#{min(hit.index + 13, 24)}"}>
            <text x="268" y={hit.y + 14} font-size="11">{hit.name}</text>
            <rect class="ehv-fill-soft" x="268" y={hit.y + 22} width="84" height="3" rx="1.5" />
            <rect
              class={[
                "ehv-a-grow-x ehv-i-#{min(hit.index + 14, 24)}",
                if(hit.index == 0, do: "ehv-fill-lime", else: "ehv-fill-3")
              ]}
              x="268"
              y={hit.y + 22}
              width={hit.bar}
              height="3"
              rx="1.5"
            />
            <text
              class={["ehv-mono", if(hit.index == 0, do: "ehv-t-lime", else: "ehv-t-2")]}
              x="384"
              y={hit.y + 26.5}
              font-size="7.5"
              text-anchor="end"
            >
              {hit.score}
            </text>
          </g>
          <text :if={@hits == []} class="ehv-t-3 ehv-a-fade ehv-i-13" x="268" y="94" font-size="11">
            Nothing in the radius
          </text>
        </g>
      </svg>
    </.frame>
    """
  end

  defp clamp(value), do: value |> max(0.0) |> min(1.0)

  # A position in the space, 0 to 1 on both axes, to a point in the plot.
  defp place(x, y) do
    {Float.round(@plot.x + @plot.pad + x * (@plot.w - 2 * @plot.pad), 1),
     Float.round(@plot.y + @plot.pad + y * (@plot.h - 2 * @plot.pad), 1)}
  end

  # A cloud of points gathered around four centres, the same for the same seed.
  defp cloud(seed, count) do
    {points, _state} =
      Enum.map_reduce(0..(count - 1), abs(seed) * 7919 + 17, fn index, state ->
        {[a, b, c, d], state} = Enum.map_reduce(1..4, state, fn _step, s -> random(s) end)
        {cx, cy} = Enum.at(@clusters, rem(index, length(@clusters)))

        {{clamp(cx + (a + b - 1) * 0.3), clamp(cy + (c + d - 1) * 0.3)}, state}
      end)

    points
  end

  # One step of a linear congruential generator: a number in 0..1 and the next state.
  defp random(state) do
    next = rem(state * 1_103_515_245 + 12_345, 2_147_483_648)
    {next / 2_147_483_648, next}
  end
end
assets/css/search/semantic.css
semantic.css
/* search/semantic */
.ehv-search-semantic { --ehv-max-w: 440px; --ehv-step: 80ms; }

.ehv-search-semantic__radius { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-width: 1; stroke-dasharray: 3 4; }
.ehv-search-semantic__tie { fill: none; stroke: var(--ehv-lime); stroke-width: 1; stroke-opacity: 0.45; }

/* A neighbour starts as any other dot and lights up once the radius reaches it. */
@keyframes ehv-search-semantic-light { from { fill: var(--ehv-ink-4); } }

.ehv-search-semantic__near { fill: var(--ehv-lime); stroke: var(--ehv-canvas); stroke-width: 1; }
.ehv--animated .ehv-search-semantic__near {
  animation: ehv-search-semantic-light 500ms var(--ehv-ease) backwards;
  animation-delay: calc(var(--ehv-delay, 0ms) + var(--ehv-i, 0) * var(--ehv-step));
}

.ehv--hover .ehv-search-semantic__radius {
  transform-box: fill-box;
  transform-origin: center;
  transition: transform 300ms var(--ehv-ease);
}
.ehv--hover:hover .ehv-search-semantic__radius { transform: scale(1.08); }