diff --git a/apps/embedding-explorer/app.js b/apps/embedding-explorer/app.js index e8e4196..27c9c9e 100644 --- a/apps/embedding-explorer/app.js +++ b/apps/embedding-explorer/app.js @@ -24,6 +24,7 @@ const secondaryText = document.querySelector('[data-secondary-text]'); const primaryCaption = document.querySelector('[data-primary-caption]'); const secondaryCaption = document.querySelector('[data-secondary-caption]'); + const visualizationSelect = document.querySelector('[data-visualization-select]'); if ( !pairSelect || @@ -108,6 +109,46 @@ }, }; + const DEFAULT_VISUALIZATION_MODE = 'diverging'; + const visualizationModes = { + diverging: { + label: 'Positive/negative heatmap', + primaryIdle: 'Pick a vector to render its heatmap.', + primaryActive(recordId) { + return `Positive/negative heatmap for ${recordId}.`; + }, + secondaryIdle(hasLeftSelection) { + return hasLeftSelection + ? 'Select a right-side vector to view its heatmap.' + : 'Choose a left vector first to enable the heatmap comparison.'; + }, + secondaryActive({ hasLeftSelection, leftId, rightId }) { + return hasLeftSelection + ? `Positive/negative heatmap comparing ${rightId} with ${leftId}.` + : `Positive/negative heatmap for ${rightId}.`; + }, + }, + binary: { + label: 'Raw binary RGB fingerprint', + primaryIdle: 'Pick a vector to render its fingerprint.', + primaryActive(recordId) { + return `Binary fingerprint for ${recordId}.`; + }, + secondaryIdle(hasLeftSelection) { + return hasLeftSelection + ? 'Select a right-side vector to view its fingerprint.' + : 'Choose a left vector first to enable fingerprint comparison.'; + }, + secondaryActive({ hasLeftSelection, leftId, rightId }) { + return hasLeftSelection + ? `Binary fingerprint comparing ${rightId} with ${leftId}.` + : `Binary fingerprint for ${rightId}.`; + }, + }, + }; + + let activeVisualizationMode = DEFAULT_VISUALIZATION_MODE; + let topPairs = []; const TOP_PAIR_LIMIT = 50; @@ -184,6 +225,110 @@ return Math.sqrt(sum); } + function getVisualizationConfig(mode) { + if (typeof mode !== 'string') { + return visualizationModes[DEFAULT_VISUALIZATION_MODE]; + } + return visualizationModes[mode] || visualizationModes[DEFAULT_VISUALIZATION_MODE]; + } + + function getVisualizationLabel(mode) { + return getVisualizationConfig(mode).label; + } + + function getActiveVisualizationMode() { + return visualizationModes[activeVisualizationMode] ? activeVisualizationMode : DEFAULT_VISUALIZATION_MODE; + } + + function setActiveVisualizationMode(mode) { + activeVisualizationMode = visualizationModes[mode] ? mode : DEFAULT_VISUALIZATION_MODE; + return activeVisualizationMode; + } + + function lerp(a, b, t) { + return a + (b - a) * t; + } + + function createBinaryVisualization(ctx, bytes) { + const dataView = bytes instanceof Uint8Array ? bytes : new Uint8Array(); + const pixelCount = Math.max(1, Math.ceil(dataView.length / 3)); + const width = Math.max(1, Math.ceil(Math.sqrt(pixelCount))); + const height = Math.max(1, Math.ceil(pixelCount / width)); + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + let byteIndex = 0; + + for (let index = 0; index < width * height; index += 1) { + data[index * 4] = dataView[byteIndex] ?? 0; + data[index * 4 + 1] = dataView[byteIndex + 1] ?? 0; + data[index * 4 + 2] = dataView[byteIndex + 2] ?? 0; + data[index * 4 + 3] = 255; + byteIndex += 3; + } + + return { width, height, imageData, mode: 'binary' }; + } + + function createDivergingVisualization(ctx, values) { + const vector = values instanceof Float32Array ? values : new Float32Array(); + if (!vector.length) { + return null; + } + + let maxMagnitude = 0; + for (let index = 0; index < vector.length; index += 1) { + const value = vector[index]; + if (Number.isFinite(value)) { + const magnitude = Math.abs(value); + if (magnitude > maxMagnitude) { + maxMagnitude = magnitude; + } + } + } + + if (!Number.isFinite(maxMagnitude) || maxMagnitude === 0) { + maxMagnitude = 1; + } + + const pixelCount = Math.max(1, vector.length); + const width = Math.max(1, Math.ceil(Math.sqrt(pixelCount))); + const height = Math.max(1, Math.ceil(pixelCount / width)); + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + + const yellow = { r: 250, g: 204, b: 21 }; + const green = { r: 22, g: 163, b: 74 }; + const red = { r: 220, g: 38, b: 38 }; + + for (let index = 0; index < width * height; index += 1) { + const rawValue = index < vector.length ? vector[index] : 0; + const finiteValue = Number.isFinite(rawValue) ? rawValue : 0; + const normalized = Math.max(-1, Math.min(1, finiteValue / maxMagnitude)); + let r = yellow.r; + let g = yellow.g; + let b = yellow.b; + + if (normalized > 0) { + const t = normalized; + r = Math.round(lerp(yellow.r, green.r, t)); + g = Math.round(lerp(yellow.g, green.g, t)); + b = Math.round(lerp(yellow.b, green.b, t)); + } else if (normalized < 0) { + const t = -normalized; + r = Math.round(lerp(yellow.r, red.r, t)); + g = Math.round(lerp(yellow.g, red.g, t)); + b = Math.round(lerp(yellow.b, red.b, t)); + } + + data[index * 4] = r; + data[index * 4 + 1] = g; + data[index * 4 + 2] = b; + data[index * 4 + 3] = 255; + } + + return { width, height, imageData, mode: 'diverging' }; + } + function ensureVectorData(record) { if (!record || typeof record !== 'object') { return; @@ -472,6 +617,9 @@ const textPlaceholder = options.textPlaceholder || 'Select a vector to view its source text.'; const captionIdle = options.captionIdle || ''; const captionActive = options.captionActive || captionIdle; + const visualizationMode = typeof options.visualizationMode === 'string' + ? options.visualizationMode + : getActiveVisualizationMode(); if (pane.caption) { pane.caption.textContent = record ? captionActive : captionIdle; @@ -499,24 +647,21 @@ ensureVectorData(record); const bytes = record.vectorBytes instanceof Uint8Array ? record.vectorBytes : new Uint8Array(); - const pixelCount = Math.ceil(bytes.length / 3); - const width = Math.max(1, Math.ceil(Math.sqrt(pixelCount))); - const height = Math.max(1, Math.ceil(pixelCount / width)); + let renderResult = null; - pane.canvas.width = width; - pane.canvas.height = height; + if (visualizationMode === 'diverging') { + renderResult = createDivergingVisualization(ctx, record.floatVector); + } - const imageData = ctx.createImageData(width, height); - const data = imageData.data; - let byteIndex = 0; - for (let index = 0; index < width * height; index += 1) { - data[index * 4] = bytes[byteIndex] ?? 0; - data[index * 4 + 1] = bytes[byteIndex + 1] ?? 0; - data[index * 4 + 2] = bytes[byteIndex + 2] ?? 0; - data[index * 4 + 3] = 255; - byteIndex += 3; + if (!renderResult) { + renderResult = createBinaryVisualization(ctx, bytes); } + const appliedMode = renderResult.mode || visualizationMode; + const visualizationLabel = getVisualizationLabel(appliedMode); + const { width, height, imageData } = renderResult; + pane.canvas.width = width; + pane.canvas.height = height; ctx.putImageData(imageData, 0, 0); const containerWidth = pane.canvas.parentElement?.clientWidth || 220; @@ -532,6 +677,7 @@ const textHashPreview = textHash ? `${textHash.slice(0, 16)}…` : '—'; const metaItems = [ + { label: 'Visualization', value: visualizationLabel }, { label: 'Vector id', value: record.id }, { label: 'Collection', value: source?.collection || '—' }, { label: 'Provider', value: record.provider || datasetMeta?.provider || '—' }, @@ -626,8 +772,10 @@ function renderPrimaryPane(record, datasetData) { const contentKey = datasetData?.contentKey || ''; const contentEntry = record ? getContentEntry(contentKey, record.id) : null; - const captionIdle = 'Pick a vector to render its fingerprint.'; - const captionActive = record ? `Binary fingerprint for ${record.id}.` : captionIdle; + const visualizationMode = getActiveVisualizationMode(); + const visualizationConfig = getVisualizationConfig(visualizationMode); + const captionIdle = visualizationConfig.primaryIdle; + const captionActive = record ? visualizationConfig.primaryActive(record.id) : captionIdle; const previewFallback = sides.left.activePreviewText || ''; renderPane(primaryPane, record, datasetData?.meta, datasetData?.source, { @@ -638,6 +786,7 @@ contentKey, contentEntry, previewFallback, + visualizationMode, }); } @@ -646,17 +795,19 @@ const contentEntry = rightRecord ? getContentEntry(contentKey, rightRecord.id) : null; const previewFallback = sides.right.activePreviewText || ''; const hasLeftSelection = Boolean(leftRecord); - const captionIdle = hasLeftSelection - ? 'Select a right-side vector to view its fingerprint.' - : 'Choose a left vector first, then pick a comparison vector.'; + const visualizationMode = getActiveVisualizationMode(); + const visualizationConfig = getVisualizationConfig(visualizationMode); + const captionIdle = visualizationConfig.secondaryIdle(hasLeftSelection); let captionActive = captionIdle; const extraMeta = []; if (rightRecord) { - captionActive = leftRecord - ? `Comparing ${rightRecord.id} with ${leftRecord.id}.` - : `Binary fingerprint for ${rightRecord.id}.`; + captionActive = visualizationConfig.secondaryActive({ + hasLeftSelection, + leftId: leftRecord?.id || '', + rightId: rightRecord.id, + }); if (leftRecord) { const similarity = computePairSimilarity(leftRecord, rightRecord); @@ -679,6 +830,7 @@ contentEntry, extraMeta, previewFallback, + visualizationMode, }); } @@ -1228,6 +1380,17 @@ loadTopPairs(); } + if (visualizationSelect) { + visualizationSelect.value = getActiveVisualizationMode(); + visualizationSelect.addEventListener('change', (event) => { + const nextMode = setActiveVisualizationMode(event.target.value); + if (visualizationSelect.value !== nextMode) { + visualizationSelect.value = nextMode; + } + renderComparison(); + }); + } + pairSelect.addEventListener('change', () => { const value = pairSelect.value; const index = Number.parseInt(value, 10); diff --git a/apps/embedding-explorer/index.html b/apps/embedding-explorer/index.html index 64e844f..69601bf 100644 --- a/apps/embedding-explorer/index.html +++ b/apps/embedding-explorer/index.html @@ -386,6 +386,19 @@ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } + .comparison__controls { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: clamp(12px, 2vw, 18px); + } + + .comparison__control-help { + margin: 0; + font-size: 0.85rem; + color: var(--text-secondary); + } + .comparison__column { display: flex; flex-direction: column; @@ -553,7 +566,17 @@

Right vectors

Vector comparison

-

Inspect the active vectors side by side, including their binary fingerprints and source text.

+

Inspect the active vectors side by side, and switch between visualization styles for their embeddings alongside the source text.

+
+ +

Use the heatmap to emphasize negative versus positive values, or fall back to the raw byte grid.

+

@@ -561,7 +584,7 @@

Selected vector

-

Pick a vector to render its fingerprint.

+

Pick a vector to render its heatmap.

Select a vector to inspect its details.

@@ -575,7 +598,7 @@

Comparison vector

-

Select a right-side vector to view its fingerprint.

+

Select a right-side vector to view its heatmap.

Pick a right-side vector to inspect its details.