search/semantic.ex
Semantic search
A query landing in an embedding space, where a radius around it lights up the nearest neighbours.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.search_semantic animated />
</div>
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
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
/* 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); }