diff --git a/package-lock.json b/package-lock.json index edbecc2..a08b511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,10 @@ { - "name": "embedding-atlas", + "name": "@embedding-atlas/workspace", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "@embedding-atlas/workspace", "workspaces": [ "packages/component", "packages/viewer", @@ -7897,6 +7898,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stemmer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stemmer/-/stemmer-2.0.1.tgz", + "integrity": "sha512-bkWvSX2JR4nSZFfs113kd4C6X13bBBrg4fBKv2pVdzpdQI2LA5pZcWzTFNdkYsiUNl13E4EzymSRjZ0D55jBYg==", + "dev": true, + "license": "MIT", + "bin": { + "stemmer": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/streamlit-component-lib": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/streamlit-component-lib/-/streamlit-component-lib-2.0.0.tgz", @@ -9998,6 +10013,7 @@ "publint": "^0.3.12", "quickselect": "^3.0.0", "simplify-js": "^1.2.4", + "stemmer": "^2.0.1", "svelte": "^5.37.3", "typescript": "^5.9.2", "vite": "^7.0.6", diff --git a/package.json b/package.json index d016661..afcfdd1 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "name": "@embedding-atlas/workspace", "private": true, "workspaces": [ "packages/component", diff --git a/packages/component/package.json b/packages/component/package.json index d0cb413..db33e40 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -47,6 +47,7 @@ "publint": "^0.3.12", "quickselect": "^3.0.0", "simplify-js": "^1.2.4", + "stemmer": "^2.0.1", "svelte": "^5.37.3", "typescript": "^5.9.2", "vite": "^7.0.6", diff --git a/packages/component/src/demo/EmbeddingViewDemo.svelte b/packages/component/src/demo/EmbeddingViewDemo.svelte index 4ca9409..ce5fb26 100644 --- a/packages/component/src/demo/EmbeddingViewDemo.svelte +++ b/packages/component/src/demo/EmbeddingViewDemo.svelte @@ -37,8 +37,8 @@ return { x: data.x[minIndex], y: data.y[minIndex], text: dataset[minIndex].text, fields: {} }; } - async function queryClusterLabels(rects: Rectangle[]): Promise { - return "label"; + async function queryClusterLabels(clusters: Rectangle[][]): Promise<(string | null)[]> { + return clusters.map(() => "label"); } diff --git a/packages/component/src/lib/embedding_view/EmbeddingViewImpl.svelte b/packages/component/src/lib/embedding_view/EmbeddingViewImpl.svelte index 6ce64f4..2447f11 100644 --- a/packages/component/src/lib/embedding_view/EmbeddingViewImpl.svelte +++ b/packages/component/src/lib/embedding_view/EmbeddingViewImpl.svelte @@ -18,7 +18,7 @@ totalCount: number | null; maxDensity: number | null; automaticLabels: AutomaticLabelsConfig | boolean; - queryClusterLabels: ((rects: Rectangle[]) => Promise) | null; + queryClusterLabels: ((clusters: Rectangle[][]) => Promise<(string | null)[]>) | null; tooltip: Selection | null; selection: Selection[] | null; querySelection: ((x: number, y: number, unitDistance: number) => Promise) | null; @@ -595,12 +595,11 @@ let newClusters = await generateClusters(renderer, 10, viewport); newClusters = newClusters.concat(await generateClusters(renderer, 5, viewport)); - statusMessage = "Generating labels (initializing)..."; + statusMessage = "Generating labels..."; if (queryClusterLabels) { + let labels = await queryClusterLabels(newClusters.map((x) => x.rects)); for (let i = 0; i < newClusters.length; i++) { - let label = await queryClusterLabels(newClusters[i].rects); - newClusters[i].label = label; - statusMessage = `Generating labels (${(((i + 1) / newClusters.length) * 100).toFixed(0)}%)...`; + newClusters[i].label = labels[i]; } } diff --git a/packages/component/src/lib/embedding_view/EmbeddingViewMosaic.svelte b/packages/component/src/lib/embedding_view/EmbeddingViewMosaic.svelte index 4ce76a3..3919584 100644 --- a/packages/component/src/lib/embedding_view/EmbeddingViewMosaic.svelte +++ b/packages/component/src/lib/embedding_view/EmbeddingViewMosaic.svelte @@ -6,7 +6,6 @@ import EmbeddingViewImpl from "./EmbeddingViewImpl.svelte"; - import { TextSummarizer } from "../text_summarizer/text_summarizer.js"; import { deepEquals, type Point, type Rectangle, type ViewportState } from "../utils.js"; import type { EmbeddingViewMosaicProps } from "./embedding_view_mosaic_api.js"; import { @@ -17,6 +16,12 @@ } from "./mosaic_client.js"; import { makeClient } from "./mosaic_helper.js"; import type { DataPoint, DataPointID } from "./types.js"; + import { + textSummarizerAdd, + textSummarizerCreate, + textSummarizerDestroy, + textSummarizerSummarize, + } from "./worker/index.js"; let { coordinator = defaultCoordinator(), @@ -328,20 +333,52 @@ } // Cluster Labels - let textSummarizer = $derived( - text != null ? new TextSummarizer({ coordinator: coordinator, table: table, x: x, y: y, text: text }) : null, - ); - - async function queryClusterLabels(rects: Rectangle[]): Promise { - if (textSummarizer == null) { - return null; + async function queryClusterLabels(clusters: Rectangle[][]): Promise<(string | null)[]> { + if (text == null) { + return clusters.map(() => null); } - let list = await textSummarizer.summarize(rects, 4); - if (list.length > 0) { - return list.slice(0, 2).join("-") + "-\n" + list.slice(2).join("-"); - } else { - return null; + // Create text summarizer (in the worker) + let summarizer = await textSummarizerCreate({ regions: clusters }); + // Add text data to the summarizer + let start = 0; + let chunkSize = 10000; + let lastAdd: Promise | null = null; + while (true) { + let r: any = await coordinator.query( + SQL.Query.from(table) + .select({ x: SQL.column(x), y: SQL.column(y), text: SQL.column(text) }) + .offset(start) + .limit(chunkSize), + ); + let data = { + x: r.getChild("x").toArray(), + y: r.getChild("y").toArray(), + text: r.getChild("text").toArray(), + }; + if (lastAdd != null) { + await lastAdd; + } + lastAdd = textSummarizerAdd(summarizer, data); + if (r.getChild("text").length < chunkSize) { + break; + } + start += chunkSize; + } + if (lastAdd != null) { + await lastAdd; } + let summarizeResult = await textSummarizerSummarize(summarizer); + await textSummarizerDestroy(summarizer); + + return summarizeResult.map((words) => { + if (words.length == 0) { + return null; + } else if (words.length > 2) { + return words.slice(0, 2).join("-") + "-\n" + words.slice(2).join("-"); + } else { + return words.join("-"); + } + }); } diff --git a/packages/component/src/lib/embedding_view/embedding_view_api.ts b/packages/component/src/lib/embedding_view/embedding_view_api.ts index 2282aa1..d35a15b 100644 --- a/packages/component/src/lib/embedding_view/embedding_view_api.ts +++ b/packages/component/src/lib/embedding_view/embedding_view_api.ts @@ -66,8 +66,8 @@ export interface EmbeddingViewProps { /** A function to query selected point given (x, y) location, and a unit distance (distance of 1pt in data units). */ querySelection?: ((x: number, y: number, unitDistance: number) => Promise) | null; - /** A function that returns a summary label for points covered by the union of the given rectangles. */ - queryClusterLabels?: ((rects: Rectangle[]) => Promise) | null; + /** A function that returns summary labels for clusters. Each cluster is given by a list of rectangles that approximate its shape. */ + queryClusterLabels?: ((clusters: Rectangle[][]) => Promise<(string | null)[]>) | null; /** A callback for when viewportState changes. */ onViewportState?: ((value: ViewportState) => void) | null; diff --git a/packages/component/src/lib/embedding_view/theme.ts b/packages/component/src/lib/embedding_view/theme.ts index 08d5eee..aae2c09 100644 --- a/packages/component/src/lib/embedding_view/theme.ts +++ b/packages/component/src/lib/embedding_view/theme.ts @@ -31,7 +31,7 @@ const defaultThemeConfig: { light: ThemeConfig; dark: ThemeConfig } = { }, dark: { fontFamily: "system-ui,sans-serif", - clusterLabelColor: "#fff", + clusterLabelColor: "#ccc", clusterLabelOutlineColor: "rgba(0,0,0,0.8)", clusterLabelOpacity: 0.8, statusBar: true, diff --git a/packages/component/src/lib/embedding_view/worker/clustering.worker.js b/packages/component/src/lib/embedding_view/worker/clustering.worker.js index 4690e26..1b2c67b 100644 --- a/packages/component/src/lib/embedding_view/worker/clustering.worker.js +++ b/packages/component/src/lib/embedding_view/worker/clustering.worker.js @@ -1,16 +1,32 @@ // Copyright (c) 2025 Apple Inc. Licensed under MIT License. -import { dynamicLabelPlacement, findClusters } from "./worker_functions.js"; +import { + dynamicLabelPlacement, + findClusters, + textSummarizerAdd, + textSummarizerCreate, + textSummarizerDestroy, + textSummarizerSummarize, +} from "./worker_functions.js"; + +/** @type Record any> */ +let functions = { + dynamicLabelPlacement, + findClusters, + textSummarizerCreate, + textSummarizerAdd, + textSummarizerDestroy, + textSummarizerSummarize, +}; onmessage = async (msg) => { - if (msg.data.name == "findClusters") { - let args = msg.data.payload; - let clusters = await findClusters(args.density_map, args.width, args.height, args.options); - postMessage({ id: msg.data.id, payload: clusters }); - } - if (msg.data.name == "dynamicLabelPlacement") { + if (functions[msg.data.name]) { + let func = functions[msg.data.name]; let args = msg.data.payload; - let result = dynamicLabelPlacement(args.labels, args.options); + let result = func(...args); + if (result instanceof Promise) { + result = await result; + } postMessage({ id: msg.data.id, payload: result }); } }; diff --git a/packages/component/src/lib/embedding_view/worker/index.ts b/packages/component/src/lib/embedding_view/worker/index.ts index 7770777..aef12a4 100644 --- a/packages/component/src/lib/embedding_view/worker/index.ts +++ b/packages/component/src/lib/embedding_view/worker/index.ts @@ -40,10 +40,26 @@ function invokeWorker(name: string, payload: any, transfer: Transferable[] = []) type PromiseReturn any> = (...args: Parameters) => Promise>; -export let findClusters: PromiseReturn = (density_map, width, height, options) => { - return invokeWorker("findClusters", { density_map, width, height, options: options }, [density_map.buffer]); +export let findClusters: PromiseReturn = (densityMap, width, height, options) => { + return invokeWorker("findClusters", [densityMap, width, height, options], [densityMap.buffer]); }; -export let dynamicLabelPlacement: PromiseReturn = (labels, options) => { - return invokeWorker("dynamicLabelPlacement", { labels, options }); +export let dynamicLabelPlacement: PromiseReturn = (...args) => { + return invokeWorker("dynamicLabelPlacement", args); +}; + +export let textSummarizerCreate: PromiseReturn = (...args) => { + return invokeWorker("textSummarizerCreate", args); +}; + +export let textSummarizerDestroy: PromiseReturn = (...args) => { + return invokeWorker("textSummarizerDestroy", args); +}; + +export let textSummarizerAdd: PromiseReturn = (...args) => { + return invokeWorker("textSummarizerAdd", args); +}; + +export let textSummarizerSummarize: PromiseReturn = (...args) => { + return invokeWorker("textSummarizerSummarize", args); }; diff --git a/packages/component/src/lib/embedding_view/worker/worker_functions.ts b/packages/component/src/lib/embedding_view/worker/worker_functions.ts index b4484f3..bfa2f20 100644 --- a/packages/component/src/lib/embedding_view/worker/worker_functions.ts +++ b/packages/component/src/lib/embedding_view/worker/worker_functions.ts @@ -2,5 +2,30 @@ import { findClusters } from "@embedding-atlas/density-clustering"; import { dynamicLabelPlacement } from "../../dynamic_label_placement/dynamic_label_placement.js"; +import { TextSummarizer } from "../../text_summarizer/text_summarizer.js"; +import type { Rectangle } from "../../utils.js"; export { dynamicLabelPlacement, findClusters }; + +let textSummarizers = new Map(); + +export function textSummarizerCreate(options: { regions: Rectangle[][]; stopWords?: string[] }) { + let key = new Date().getTime() + "-" + Math.random(); + textSummarizers.set(key, new TextSummarizer(options)); + return key; +} + +export function textSummarizerDestroy(key: string) { + return textSummarizers.delete(key); +} + +export function textSummarizerAdd( + key: string, + data: { x: ArrayLike; y: ArrayLike; text: ArrayLike } +) { + textSummarizers.get(key)?.add(data); +} + +export function textSummarizerSummarize(key: string) { + return textSummarizers.get(key)?.summarize() ?? []; +} diff --git a/packages/component/src/lib/text_summarizer/text_summarizer.ts b/packages/component/src/lib/text_summarizer/text_summarizer.ts index 166944d..1320c4f 100644 --- a/packages/component/src/lib/text_summarizer/text_summarizer.ts +++ b/packages/component/src/lib/text_summarizer/text_summarizer.ts @@ -1,98 +1,152 @@ // Copyright (c) 2025 Apple Inc. Licensed under MIT License. -import { column, literal, sql } from "@uwdata/mosaic-sql"; +import { stemmer } from "stemmer"; import type { Rectangle } from "../utils.js"; -import { stopWords } from "./stop_words.js"; +import { stopWords as defaultStopWords } from "./stop_words.js"; -/** A text summarizer based on c-TF-IDF, all implemented as SQL queries. */ +/** A text summarizer based on c-TF-IDF (https://arxiv.org/pdf/2203.05794) */ export class TextSummarizer { - private coordinator: any; - private tableName: string; - private xColumn: string; - private yColumn: string; - private textColumn: string; - private derivedTableDF: string; - private derivedTableBins: string; - private initialized: boolean; - private xBinSize: number; - private yBinSize: number; - private x0: number; - private y0: number; - - constructor(options: { coordinator: any; table: string; text: string; x: string; y: string }) { - this.coordinator = options.coordinator; - this.tableName = options.table; - this.xColumn = options.x; - this.yColumn = options.y; - this.textColumn = options.text; - - this.derivedTableDF = this.tableName + "_df"; - this.derivedTableBins = this.tableName + "_bt"; - this.initialized = false; - this.xBinSize = 1; - this.yBinSize = 1; - this.x0 = 0; - this.y0 = 0; + private segmenter: Intl.Segmenter; + private binning: XYBinning; + private stopWords: Set; + private key2RegionIndices: Map; + private frequencyPerClass: Map[]; + private frequencyAll: Map; + + /** Create a new TextSummarizer */ + constructor(options: { regions: Rectangle[][]; stopWords?: string[] }) { + this.binning = XYBinning.inferFromRegions(options.regions); + this.segmenter = new Intl.Segmenter(undefined, { granularity: "word" }); + this.stopWords = new Set(options.stopWords ?? defaultStopWords); + + this.frequencyPerClass = options.regions.map(() => new Map()); + this.frequencyAll = new Map(); + + // Generate key2RegionIndices, a map from xy key to region index + this.key2RegionIndices = new Map(); + for (let i = 0; i < options.regions.length; i++) { + let keys = this.binning.keys(options.regions[i]); + for (let k of keys) { + let v = this.key2RegionIndices.get(k); + if (v != null) { + v.push(i); + } else { + this.key2RegionIndices.set(k, [i]); + } + } + } } - private async initialize(): Promise { - if (this.initialized) { - return; + /** Add data to the summarizer */ + add(data: { x: ArrayLike; y: ArrayLike; text: ArrayLike }) { + for (let i = 0; i < data.text.length; i++) { + let key = this.binning.key(data.x[i], data.y[i]); + let indices = this.key2RegionIndices.get(key); + if (indices == null) { + continue; + } + let words = []; + for (let s of this.segmenter.segment(data.text[i])) { + let word = s.segment.trim(); + if (word.length > 1) { + words.push(word); + } + } + let inc = 1 / words.length; + for (let word of words) { + for (let idx of indices) { + incrementMap(this.frequencyPerClass[idx], word, inc); + } + incrementMap(this.frequencyAll, word, inc); + } } - let xColumn = column(this.xColumn); - let yColumn = column(this.yColumn); - let textColumn = column(this.textColumn); - - let r = await this.coordinator.query(sql` - SELECT - MIN(${xColumn}) AS xMin, QUANTILE_CONT(${xColumn}, 0.99) - QUANTILE_CONT(${xColumn}, 0.01) AS xDiff, - MIN(${yColumn}) AS yMin, QUANTILE_CONT(${yColumn}, 0.99) - QUANTILE_CONT(${yColumn}, 0.01) AS yDiff, - COUNT(*) AS count - FROM ${this.tableName} - `); - let { xMin, yMin, xDiff, yDiff, count } = r.get(0); - - this.x0 = xMin; - this.y0 = yMin; - this.xBinSize = xDiff / 200; - this.yBinSize = yDiff / 200; - let minCount = count < 10000 ? 1 : 5; - await this.coordinator.exec(sql` - - `); - await this.coordinator.exec(sql` - CREATE OR REPLACE TEMP MACRO embedding_view_tokenize(s) AS - unnest(string_split_regex(regexp_replace(lower(s), '[^a-z0-9'']', ' ', 'g'), '\\s+')); - - CREATE OR REPLACE TABLE ${this.derivedTableBins} AS ( - WITH tokens_all AS ( - SELECT - floor((${xColumn} - ${this.x0}) / ${this.xBinSize})::INT + 32768 * (floor((${yColumn} - ${this.y0}) / ${this.yBinSize})::INT) as xykey, - embedding_view_tokenize(${textColumn}) AS token - FROM ${this.tableName} - ) - SELECT xykey, token, COUNT(*) AS count - FROM tokens_all - WHERE token NOT IN ('',${stopWords.map((x) => literal(x)).join(",")}) AND LENGTH(token) >= 3 - GROUP BY xykey, token - HAVING count >= ${minCount} - ); - CREATE OR REPLACE TABLE ${this.derivedTableDF} AS ( - SELECT sum(count) AS count, stem(token, 'english') AS stem_token - FROM ${this.derivedTableBins} GROUP BY stem_token + } + + summarize(limit: number = 4): string[][] { + // Aggregate the frequencies by stemmed words + let frequencyAllStem = aggregateByStem(this.frequencyAll, this.stopWords); + let frequencyPerClassStem = this.frequencyPerClass.map((m) => aggregateByStem(m, this.stopWords)); + + // Average number of words per class + let averageWords = + frequencyPerClassStem.map((x) => x.values().reduce((a, b) => a + b[1], 0)).reduce((a, b) => a + b, 0) / + frequencyPerClassStem.length; + + return frequencyPerClassStem.map((wordMap) => { + // Compute TF-IDF + let entries = Array.from( + wordMap.entries().map(([key, [word, tf]]) => { + let df = frequencyAllStem.get(key)?.[1] ?? 1; + let idf = Math.log(1 + averageWords / df); + return { + word: word, + tf: tf, + df: df, + idf: idf, + tfIDF: tf * idf, + }; + }), ); - `); - this.initialized = true; + entries = entries.filter((x) => x.df >= 2); + entries = entries.sort((a, b) => b.tfIDF - a.tfIDF); + return entries.slice(0, limit).map((x) => x.word); + }); } +} + +class XYBinning { + private xMin: number; + private yMin: number; + private xStep: number; + private yStep: number; - private indices(rects: Rectangle[]): number[] { + constructor(xMin: number, yMin: number, xStep: number, yStep: number) { + this.xMin = xMin; + this.yMin = yMin; + this.xStep = xStep; + this.yStep = yStep; + } + + static inferFromRegions(regions: Rectangle[][]): XYBinning { + let xMin = Number.POSITIVE_INFINITY; + let yMin = Number.POSITIVE_INFINITY; + let xMax = Number.NEGATIVE_INFINITY; + let yMax = Number.NEGATIVE_INFINITY; + for (let region of regions) { + for (let rect of region) { + if (rect.xMin < xMin) { + xMin = rect.xMin; + } else if (rect.xMax > xMax) { + xMax = rect.xMax; + } + if (rect.yMin < yMin) { + yMin = rect.yMin; + } else if (rect.yMax > yMax) { + yMax = rect.yMax; + } + } + } + if (xMin < xMax && yMin < yMax) { + return new XYBinning(xMin, yMin, (xMax - xMin) / 200, (yMax - yMin) / 200); + } else { + return new XYBinning(0, 0, 1, 1); + } + } + + key(x: number, y: number) { + let ix = Math.floor((x - this.xMin) / this.xStep); + let iy = Math.floor((y - this.yMin) / this.yStep); + return ix + iy * 32768; + } + + keys(rects: Rectangle[]): Set { let keys = new Set(); for (let { xMin, yMin, xMax, yMax } of rects) { - let xiLowerBound = Math.floor((xMin - this.x0) / this.xBinSize); - let xiUpperBound = Math.floor((xMax - this.x0) / this.xBinSize); - let yiLowerBound = Math.floor((yMin - this.y0) / this.yBinSize); - let yiUpperBound = Math.floor((yMax - this.y0) / this.yBinSize); + let xiLowerBound = Math.floor((xMin - this.xMin) / this.xStep); + let xiUpperBound = Math.floor((xMax - this.xMin) / this.xStep); + let yiLowerBound = Math.floor((yMin - this.yMin) / this.yStep); + let yiUpperBound = Math.floor((yMax - this.yMin) / this.yStep); for (let xi = xiLowerBound; xi <= xiUpperBound; xi++) { for (let yi = yiLowerBound; yi <= yiUpperBound; yi++) { let p = yi * 32768 + xi; @@ -100,36 +154,34 @@ export class TextSummarizer { } } } - return Array.from(keys); + return keys; } +} + +function incrementMap(map: Map, key: K, value: number) { + let c = map.get(key) ?? 0; + map.set(key, c + value); +} - async summarize(rects: Rectangle[], limit: number = 4): Promise { - await this.initialize(); - let indices = this.indices(rects); - let q = sql` - WITH tokens_tf AS ( - SELECT token, sum(count) AS count - FROM ${this.derivedTableBins} - WHERE xykey IN (${indices.join(",")}) - GROUP BY token - ), - tokens_tf_stem AS ( - SELECT sum(count) AS count, stem(token, 'english') AS stem_token, ARG_MAX(token, count) AS token - FROM tokens_tf - GROUP BY stem_token - ) - SELECT - tokens_tf_stem.count AS tf, - ${this.derivedTableDF}.count AS df, - tf * log(1 + (SELECT sum(count) FROM tokens_tf_stem) / df) AS tfidf, - tokens_tf_stem.token AS token - FROM ${this.derivedTableDF}, tokens_tf_stem - WHERE ${this.derivedTableDF}.stem_token == tokens_tf_stem.stem_token - ORDER BY tfidf DESC limit ${limit} - `; - // TODO: try to directly call the DuckDB instance to see if perf is better. - let result = await this.coordinator.query(q); - let list = result.getChild("token").toArray(); - return list; +/** Aggregate words by their stems and track the most frequent version. + * Returns a map with stemmed words as keys, and the most frequent version and total count as values. */ +function aggregateByStem(inputMap: Map, stopWords: Set): Map { + let result = new Map(); + for (let [word, count] of inputMap.entries()) { + let lower = word.toLowerCase(); + if (stopWords.has(lower) || /^[0-9]+$/.test(lower)) { + continue; + } + let s = stemmer(lower); + if (result.has(s)) { + let value = result.get(s); + value[1] += count; + if ((inputMap.get(value[0]) ?? 0) < count) { + value[0] = word; + } + } else { + result.set(s, [word, count]); + } } + return result; } diff --git a/packages/docs/embedding-view.md b/packages/docs/embedding-view.md index ca99714..df01957 100644 --- a/packages/docs/embedding-view.md +++ b/packages/docs/embedding-view.md @@ -191,8 +191,8 @@ of a single pixel in data domain. You can use this to determine the distance thr ### queryClusterLabels `Function | null` -An async function of type `(rects: Rectangle[]) => Promise`, that returns a cluster label -for the points covered by the given set of rectangles. +An async function of type `(clusters: Rectangle[][]) => Promise<(string | null)[]>`, +that returns labels for a list of clusters. Each cluster is given as a list of rectangles that approximately cover the region. ## Custom Tooltip diff --git a/packages/examples/src/svelte/EmbeddingViewExample.svelte b/packages/examples/src/svelte/EmbeddingViewExample.svelte index 1341865..195c9b9 100644 --- a/packages/examples/src/svelte/EmbeddingViewExample.svelte +++ b/packages/examples/src/svelte/EmbeddingViewExample.svelte @@ -37,8 +37,8 @@ return { x: data.x[minIndex], y: data.y[minIndex], text: dataset[minIndex].text, fields: {} }; } - async function queryClusterLabels(rects: Rectangle[]): Promise { - return "label"; + async function queryClusterLabels(clusters: Rectangle[][]): Promise<(string | null)[]> { + return clusters.map(() => "label"); }