diff --git a/apps/client/src/components/ui/agent-avatar-stack.test.tsx b/apps/client/src/components/ui/agent-avatar-stack.test.tsx index bbcca423..aa97ec43 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.test.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -2,7 +2,8 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { Provider, createStore } from "jotai"; -import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack"; +import { AgentAvatarStack } from "./agent-avatar-stack"; +import { avatarStyle } from "@/lib/avatar-palette"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, @@ -13,14 +14,16 @@ import { type Props = React.ComponentProps; -// The DOM normalizes an inline `background: hsl(...)` to `rgb(...)`. Push the +// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the // expected color through the same CSSOM path so the comparison stays exact and // non-vacuous (an empty string — i.e. no inline background, as in the pre-fix -// Avatar approach — can never match a real color). +// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not +// round-trip a `linear-gradient` in the `background` shorthand, which is why the +// glyph carries an explicit solid `background-color` we assert on here. function normalizeColor(value: string): string { const probe = document.createElement("div"); - probe.style.background = value; - return probe.style.background; + probe.style.backgroundColor = value; + return probe.style.backgroundColor; } function renderStack(props: Props) { @@ -36,26 +39,6 @@ function renderStack(props: Props) { return { store, ...utils }; } -describe("agentGlyphBackground", () => { - it("is deterministic for a given agent name", () => { - expect(agentGlyphBackground("Researcher")).toBe( - agentGlyphBackground("Researcher"), - ); - }); - - it("gives categorically different colors to different agents", () => { - // The two agents that looked identically violet in the report must differ. - expect(agentGlyphBackground("Структурный редактор")).not.toBe( - agentGlyphBackground("Фактчекер"), - ); - expect(agentGlyphBackground("Researcher")).not.toBe( - agentGlyphBackground("Нарратор"), - ); - // Every color is a dark hsl circle drawn from the palette. - expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); - }); -}); - describe("AgentAvatarStack", () => { it("internal chat WITH role: emoji glyph + human launcher badge in front", () => { const { container } = renderStack({ @@ -73,8 +56,8 @@ describe("AgentAvatarStack", () => { expect(screen.getByText("Alice")).toBeDefined(); }); - it("emoji glyph applies its per-agent color as an inline DOM background", () => { - // Pins the actual fix: the hashed color must reach the DOM as an inline + it("emoji glyph applies its per-agent gradient as an inline DOM background", () => { + // Pins the actual fix: the hashed gradient must reach the DOM as an inline // `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no // inline background (Mantine's --avatar-bg overrode it), so this fails there. const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null }; @@ -88,20 +71,19 @@ describe("AgentAvatarStack", () => { '[data-testid="agent-glyph"]', ); expect(glyph).not.toBeNull(); - // Non-vacuous: compare against the function output (normalized the same way), - // not a frozen literal. Empty against the pre-fix Avatar (no inline bg). - expect(glyph!.style.background).not.toBe(""); - expect(glyph!.style.background).toBe( - normalizeColor(agentGlyphBackground(agent.name)), - ); + const expected = normalizeColor(avatarStyle(agent.name).bg); + // Non-vacuous: the pre-fix Avatar set no inline background at all. + expect(expected).not.toBe(""); + expect(glyph!.style.backgroundColor).toBe(expected); + // (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does + // not round-trip linear-gradient — so its stops/angle are covered by the + // avatarStyle unit tests above, not asserted on the DOM here.) }); - it("agents with distinct hashed colors reach the DOM as distinct backgrounds", () => { + it("agents with distinct styles reach the DOM as distinct backgrounds", () => { // "Researcher" and "Нарратор" hash to different palette entries, so their // applied DOM backgrounds must differ — pins "distinct colors reach the DOM". - expect(agentGlyphBackground("Researcher")).not.toBe( - agentGlyphBackground("Нарратор"), - ); + expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg); const a = renderStack({ agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, @@ -120,14 +102,9 @@ describe("AgentAvatarStack", () => { const glyphB = b.container.querySelector( '[data-testid="agent-glyph"]', ); - expect(glyphA!.style.background).toBe( - normalizeColor(agentGlyphBackground("Researcher")), - ); - expect(glyphB!.style.background).toBe( - normalizeColor(agentGlyphBackground("Нарратор")), - ); - // Different colors reach the DOM (the normalized rgb values also differ). - expect(glyphA!.style.background).not.toBe(glyphB!.style.background); + expect(glyphA!.style.backgroundColor).not.toBe(""); + // Different base colors reach the DOM (the serialized rgb values differ). + expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor); }); it("showName=false: renders only the avatars, no inline name label", () => { diff --git a/apps/client/src/components/ui/agent-avatar-stack.tsx b/apps/client/src/components/ui/agent-avatar-stack.tsx index f2b7fbab..0f6a8a06 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -4,6 +4,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useSetAtom } from "jotai"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, @@ -29,54 +30,11 @@ const LAUNCHER_SIZE = 22; // sits as a small badge over that corner (above the glyph) and stays fully visible. const LAUNCHER_OVERHANG = 8; -// Small deterministic string hash (same algorithm as custom-avatar's initials -// hash) used to pick a stable per-agent glyph color. -function hashName(input: string): number { - let hash = 0; - for (let i = 0; i < input.length; i += 1) { - hash = (hash << 5) - hash + input.charCodeAt(i); - hash |= 0; - } - return Math.abs(hash); -} - -// A palette of categorically-DISTINCT dark circle colors for emoji/sparkles agent -// glyphs. Every entry is intentionally dark (low lightness) so a bright emoji or -// the white sparkles icon stays readable on top; the hues are spread across the -// wheel (red → orange → amber → green → teal → cyan → blue → indigo → violet → -// magenta + a neutral slate) so two different agents read as DIFFERENT colors, -// not merely different shades of the same violet. -const GLYPH_COLORS = [ - "hsl(355, 60%, 34%)", // red - "hsl(18, 62%, 32%)", // vermilion - "hsl(32, 60%, 30%)", // orange - "hsl(45, 55%, 28%)", // amber - "hsl(75, 45%, 26%)", // olive-green - "hsl(140, 48%, 26%)", // green - "hsl(165, 52%, 26%)", // teal - "hsl(188, 58%, 28%)", // cyan - "hsl(205, 58%, 32%)", // sky blue - "hsl(225, 52%, 36%)", // blue - "hsl(250, 48%, 38%)", // indigo - "hsl(280, 46%, 36%)", // violet - "hsl(312, 48%, 34%)", // magenta - "hsl(210, 12%, 36%)", // slate / neutral -]; - -/** - * Deterministic dark circle color for an emoji/sparkles agent glyph, picked from - * GLYPH_COLORS by a hash of the agent name so distinct agents get categorically - * distinct colors while every color stays dark enough to keep the glyph readable. - */ -export function agentGlyphBackground(name: string): string { - return GLYPH_COLORS[hashName(name) % GLYPH_COLORS.length]; -} - /** * The front avatar. Image-source priority (#300): * 1. agent.avatarUrl -> a real avatar image (external MCP agent account). - * 2. agent.emoji -> the role emoji on a per-agent dark circle. - * 3. otherwise -> the IconSparkles glyph on a per-agent dark circle (fallback). + * 2. agent.emoji -> the role emoji on a per-agent gradient circle. + * 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle. */ function AgentGlyph({ agent }: { agent: AgentInfo }) { if (agent.avatarUrl) { @@ -89,10 +47,13 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) { ); } - // Emoji/sparkles glyph on a per-agent dark circle (color hashed from the agent - // name). Rendered as a plain Box, NOT a Mantine `Avatar variant="filled"`, so - // the background is guaranteed instead of being overridden by Mantine's - // `--avatar-bg` (which was falling back to the theme's violet for every agent). + // Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner + // and split angle all hashed from the agent name via avatarStyle — see + // @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine + // `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background + // (every agent fell back to the theme's violet). The foreground (the sparkles + // icon) uses the ring's WCAG-checked readable text color. + const style = avatarStyle(agent.name); return ( { + it("palette colors stay distinguishable", () => { + // 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future + // RINGS tweak drops this, "almost identical" colors would reappear. + expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06); + expect(PALETTE.length).toBe(20); + }); + + it("every palette entry is WCAG-readable and in sRGB gamut", () => { + // white text = luminance 1, black text = luminance 0 (per buildPalette). + const textLum = { white: 1, black: 0 } as const; + for (const entry of PALETTE) { + expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/); + + // (a) The chosen text color really clears the code's 3:1 threshold on the + // actual background hex — recomputed independently from the hex, not from + // the build-time luminance. A slot that picked the wrong text (or a color + // too dim for either text) would fail here. + const hexLum = relativeLuminance(hexToRgb01(entry.hex)); + const chosen = contrastRatio(textLum[entry.text], hexLum); + expect(chosen).toBeGreaterThanOrEqual(3); + // buildPalette prefers white and only falls back to black when white + // fails 3:1. Mirror that decision: black is used *only* when white would + // not clear the threshold — so a mis-assigned "black" on a dark color + // (where white was fine) fails here. + if (entry.text === "black") { + expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3); + } + + // (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping; + // an out-of-gamut slot (e.g. un-clamped chroma) would produce components + // outside [0,1] and fail here. + expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true); + } + }); +}); + +describe("avatarStyle", () => { + it("name-to-avatar mapping is frozen (golden values)", () => { + // Golden slice: if this breaks, all existing avatars change — make sure + // that is intentional (a config change in avatar-palette.ts). + const s = avatarStyle("Backend Developer"); + expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]); + expect(s.text).toBe("white"); + }); + + it("is deterministic and normalizes the name", () => { + expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher")); + // Casing, surrounding and repeated whitespace must not change the avatar. + expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher")); + expect(avatarStyle("Backend Developer")).toEqual( + avatarStyle("backend developer"), + ); + expect(normalizeName(" PM ")).toBe("pm"); + }); + + it("returns a valid base color, angle and matching text", () => { + const s = avatarStyle("Нарратор"); + const idx = PALETTE.findIndex((e) => e.hex === s.bg); + expect(idx).toBe(s.paletteIndex); + expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry + // Text color comes from the chosen palette entry. + expect(s.text).toBe(PALETTE[idx].text); + // Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15. + expect(s.angleDeg % 15).toBe(0); + expect(s.angleDeg).toBeGreaterThanOrEqual(0); + expect(s.angleDeg).toBeLessThan(360); + }); + + it("distinguishes the agents that used to collide as violet", () => { + // "Структурный редактор" and "Фактчекер" looked identically violet before. + expect(avatarStyle("Структурный редактор")).not.toEqual( + avatarStyle("Фактчекер"), + ); + }); +}); + +describe("avatarBackgroundCss", () => { + it("renders a two-stop gradient with a soft boundary", () => { + const s = avatarStyle("Backend Developer"); + expect(avatarBackgroundCss(s)).toBe( + "linear-gradient(150deg, #a55795 42%, #90355e 58%)", + ); + }); +}); diff --git a/apps/client/src/lib/avatar-palette.ts b/apps/client/src/lib/avatar-palette.ts new file mode 100644 index 00000000..8432412c --- /dev/null +++ b/apps/client/src/lib/avatar-palette.ts @@ -0,0 +1,267 @@ +/** + * Deterministic avatar backgrounds for agent roles. + * + * The palette is generated from scratch at module load in OKLCH (a perceptually + * uniform color space), so every value below is tunable: change the ring + * configuration or the partner shifts and the whole palette regenerates. + * + * Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields: + * - base color index (one of the validated palette colors) + * - partner hue shift: analogous 20..45deg (either side), complementary 180deg, + * or triadic +/-120deg — classic color-wheel schemes; partner is also darker + * - split angle (SPLIT_ANGLE_STEPS directions, soft boundary) + * The same name always yields the same avatar, on any platform, forever. + */ + +// ------------------------- Tunable configuration ------------------------- + +export interface RingConfig { + /** OKLCH lightness, 0..1 */ + L: number; + /** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */ + C: number; + /** Hue of the first color in the ring, degrees */ + hueStart: number; + /** Number of evenly spaced hues in the ring */ + count: number; +} + +/** + * Two lightness rings. 12 light + 8 dark = 20 base colors with a validated + * min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size). + * Don't add more hues per ring without re-checking minPairwiseDistance(): + * beyond ~20-24 colors humans stop telling them apart reliably. + */ +const RINGS: readonly RingConfig[] = [ + { L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring + { L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring +]; + +/** Partner color: lightness shifted by this much (negative = darker) */ +const PARTNER_L_SHIFT = -0.10; +/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */ +const ANALOG_MIN_SHIFT = 20; +const ANALOG_SHIFT_STEP = 5; +const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45 +/** Complementary scheme: fixed hue shift, degrees */ +const COMPLEMENTARY_SHIFT = 180; +/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */ +const TRIADIC_SHIFT = 120; +/** Number of split directions (24 -> 15deg per step) */ +const SPLIT_ANGLE_STEPS = 24; +/** Position of the color boundary, percent of the gradient axis */ +const SPLIT_PERCENT = 50; +/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */ +const SPLIT_SOFTNESS = 16; + +// ------------------------- OKLCH -> sRGB math ------------------------- +// Matrices from Bjorn Ottosson's OKLab reference implementation. + +function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3; + return [ + +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + ]; +} + +function gammaEncode(c: number): number { + return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; +} + +export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] { + const h = (hDeg * Math.PI) / 180; + const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h)); + return [gammaEncode(r), gammaEncode(g), gammaEncode(b)]; +} + +export function isInGamut(rgb: readonly number[]): boolean { + return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6); +} + +/** Binary-search the max chroma <= C that fits into the sRGB gamut. */ +function clampChroma(L: number, C: number, hDeg: number): number { + if (isInGamut(oklchToSrgb(L, C, hDeg))) return C; + let lo = 0, hi = C; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid; + else hi = mid; + } + return lo; +} + +function toHex(rgb: readonly number[]): string { + return ( + "#" + + rgb + .map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")) + .join("") + ); +} + +/** WCAG relative luminance of an sRGB color (components 0..1). */ +export function relativeLuminance(rgb: readonly number[]): number { + const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +} + +export function contrastRatio(l1: number, l2: number): number { + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +// ------------------------- Palette generation ------------------------- + +export interface PaletteEntry { + /** Base background color */ + hex: string; + /** OKLCH coordinates of the base color (used to derive partner colors) */ + L: number; + C: number; + h: number; + /** Text/icon color with the best WCAG contrast on the base color */ + text: "white" | "black"; + /** OKLab coordinates of the base color (kept for validation) */ + lab: readonly [number, number, number]; +} + +function buildPalette(): PaletteEntry[] { + const entries: PaletteEntry[] = []; + for (const ring of RINGS) { + const step = 360 / ring.count; + for (let i = 0; i < ring.count; i++) { + const h = (ring.hueStart + i * step) % 360; + const C = clampChroma(ring.L, ring.C, h); + const rgb = oklchToSrgb(ring.L, C, h); + const lum = relativeLuminance(rgb); + entries.push({ + hex: toHex(rgb), + L: ring.L, + C, + h, + // White text needs >= 3:1 contrast; otherwise fall back to black. + text: contrastRatio(lum, 1) >= 3 ? "white" : "black", + lab: [ + ring.L, + C * Math.cos((h * Math.PI) / 180), + C * Math.sin((h * Math.PI) / 180), + ], + }); + } + } + return entries; +} + +/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */ +function partnerHex(entry: PaletteEntry, shiftDeg: number): string { + const h2 = (entry.h + shiftDeg + 360) % 360; + const L2 = entry.L + PARTNER_L_SHIFT; + return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2)); +} + +/** Generated once at module load; regenerates on every build from the config above. */ +export const PALETTE: readonly PaletteEntry[] = buildPalette(); + +// ------------------------- Name -> avatar style ------------------------- + +/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */ +export function normalizeName(name: string): string { + return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * cyrb53: deterministic 53-bit string hash with good avalanche. + * Pure JS, cross-platform — never use language built-in hashing here. + */ +function cyrb53(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +export interface AvatarStyle { + /** Index of the base color in PALETTE */ + paletteIndex: number; + /** Base color hex */ + bg: string; + /** Second color hex (split partner) */ + bg2: string; + /** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */ + hueShift: number; + /** Direction of the split, degrees */ + angleDeg: number; + /** Text/icon color for the base color */ + text: "white" | "black"; +} + +/** Pure function: the same (normalized) name always returns the same style. */ +export function avatarStyle(agentName: string): AvatarStyle { + const h = cyrb53(normalizeName(agentName)); + // Slice the hash into independent fields, like digits of a number: + const paletteIndex = h % PALETTE.length; + let rest = Math.floor(h / PALETTE.length); + const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS); + rest = Math.floor(rest / SPLIT_ANGLE_STEPS); + // Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic + const scheme = rest % 4; + rest = Math.floor(rest / 4); + let hueShift: number; + if (scheme === 2) { + hueShift = COMPLEMENTARY_SHIFT; + } else if (scheme === 3) { + hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT; + } else { + const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP; + hueShift = scheme === 0 ? -magnitude : magnitude; + } + const entry = PALETTE[paletteIndex]; + return { + paletteIndex, + bg: entry.hex, + bg2: partnerHex(entry, hueShift), + hueShift, + angleDeg, + text: entry.text, + }; +} + +/** CSS background value: two colors with a slightly blurred boundary. */ +export function avatarBackgroundCss(style: AvatarStyle): string { + const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2; + const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2; + return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`; +} + +// ------------------------- Validation ------------------------- + +/** + * Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors. + * Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors + * look alike. Intended for a unit test or a dev-time assertion. + */ +export function minPairwiseDistance(): { distance: number; pair: [string, string] } { + let min = Infinity; + let pair: [string, string] = ["", ""]; + for (let i = 0; i < PALETTE.length; i++) { + for (let j = i + 1; j < PALETTE.length; j++) { + const a = PALETTE[i].lab, b = PALETTE[j].lab; + const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + if (d < min) { + min = d; + pair = [PALETTE[i].hex, PALETTE[j].hex]; + } + } + } + return { distance: min, pair }; +}