Skip to content

Commit 056df4d

Browse files
authored
feat: add options to customize tooltip (#41)
Add more options to customize tooltip: - For the "Text Style" options, currently there is only a text renderer option for each column. We will change to this: - Rename title "Text Style" to "Column Styles" - Add a dropdown with "Hidden", "Minified", and "Full" to allow customizing how each column is displayed in the tooltip. - When the display type is image, have a option to set the image size. - In settings panel, remove the "Tooltip" dropdown --- it's superseded by above. - For the search result box, use the same component as the tooltip, for consistency. Minor fixes and styling adjustments: - Clicking the nearest neighbor in search result should trigger the tooltip (just like clicking a row in the table) - Add a little bit of transparency and background blur for tooltip and legend.
1 parent b8c54f3 commit 056df4d

21 files changed

+504
-222
lines changed

packages/backend/start_image.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/bin/bash
22

3-
uv run embedding-atlas ylecun/mnist --image image --split train --static ../viewer/dist
3+
uv run embedding-atlas ylecun/mnist --image image --split train --static ../viewer/dist "$@"

packages/table/src/lib/views/cells/CellContent.svelte

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
<script lang="ts">
33
import type { JSType } from "@uwdata/mosaic-core";
44
5+
import BigIntContent from "./cell-contents/BigIntContent.svelte";
6+
import CustomCellContents from "./cell-contents/CustomCellContents.svelte";
7+
import ImageContent from "./cell-contents/ImageContent.svelte";
58
import LinkContent from "./cell-contents/LinkContent.svelte";
69
import NumberContent from "./cell-contents/NumberContent.svelte";
710
import TextContent from "./cell-contents/TextContent.svelte";
811
912
import { ConfigContext } from "../../context/config.svelte";
1013
import { Context } from "../../context/context.svelte";
1114
import { CustomCellsContext } from "../../context/custom-cells.svelte";
12-
import BigIntContent from "./cell-contents/BigIntContent.svelte";
13-
import CustomCellContents from "./cell-contents/CustomCellContents.svelte";
1415
1516
interface Props {
1617
row: string;
@@ -34,6 +35,24 @@
3435
const content: string | null = model.getContent({ row, col });
3536
const type: JSType = schema.dataType[col] ?? "string";
3637
const sqlType: string = schema.sqlType[col] ?? "TEXT";
38+
39+
function isLink(value: any): boolean {
40+
return typeof value == "string" && (value.startsWith("http://") || value.startsWith("https://"));
41+
}
42+
43+
function isImage(value: any): boolean {
44+
if (value == null) {
45+
return false;
46+
}
47+
if (typeof value == "string" && value.startsWith("data:image/")) {
48+
return true;
49+
}
50+
if (value.bytes && value.bytes instanceof Uint8Array) {
51+
// TODO: check if the bytes are actually an image.
52+
return true;
53+
}
54+
return false;
55+
}
3756
</script>
3857

3958
<div
@@ -45,7 +64,7 @@
4564
{#if customCellsConfig[col]}
4665
<CustomCellContents row={row} col={col} customCell={customCellsConfig[col]} bind:height={contentHeight} />
4766
{:else if type === "string"}
48-
{#if content && content.startsWith("http")}
67+
{#if content && isLink(content)}
4968
<LinkContent url={content} bind:height={contentHeight} />
5069
{:else}
5170
<TextContent text={content} bind:height={contentHeight} clamped={clamped} parentHeight={height} />
@@ -56,6 +75,8 @@
5675
{:else}
5776
<NumberContent number={content as number | null} bind:height={contentHeight} />
5877
{/if}
78+
{:else if isImage(content)}
79+
<ImageContent image={content} bind:height={contentHeight} />
5980
{:else}
6081
<TextContent text={content} bind:height={contentHeight} clamped={clamped} parentHeight={height} />
6182
{/if}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<!-- Copyright (c) 2025 Apple Inc. Licensed under MIT License. -->
2+
<script lang="ts">
3+
import { ConfigContext } from "../../../context/config.svelte";
4+
5+
interface Props {
6+
image: any | null;
7+
height: number;
8+
}
9+
10+
let { image, height = $bindable() }: Props = $props();
11+
12+
const config = ConfigContext.config;
13+
14+
let element: HTMLElement | null = $state(null);
15+
16+
function base64Encode(data: Uint8Array): string {
17+
let binary = "";
18+
for (let i = 0; i < data.length; i++) {
19+
binary += String.fromCharCode(data[i]);
20+
}
21+
return btoa(binary);
22+
}
23+
24+
function base64Decode(base64: string): Uint8Array {
25+
const binaryString = atob(base64);
26+
return new Uint8Array([...binaryString].map((char) => char.charCodeAt(0)));
27+
}
28+
29+
function startsWith(data: Uint8Array, prefix: number[]): boolean {
30+
if (data.length < prefix.length) {
31+
return false;
32+
}
33+
for (let i = 0; i < prefix.length; i++) {
34+
if (data[i] != prefix[i]) {
35+
return false;
36+
}
37+
}
38+
return true;
39+
}
40+
41+
function detectImageType(data: Uint8Array): string {
42+
if (startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
43+
return "image/png";
44+
} else if (startsWith(data, [0xff, 0xd8, 0xff])) {
45+
return "image/jpeg";
46+
} else if (startsWith(data, [0x49, 0x49, 0x2a, 0x00])) {
47+
return "image/tiff";
48+
} else if (startsWith(data, [0x42, 0x4d])) {
49+
return "image/bmp";
50+
} else if (
51+
startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
52+
startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61])
53+
) {
54+
return "image/gif";
55+
}
56+
// Unknown, fallback to generic type
57+
return "application/octet-stream";
58+
}
59+
60+
function imageToDataUrl(img: any): string | null {
61+
if (img == null) {
62+
return null;
63+
}
64+
if (typeof img == "string") {
65+
if (img.startsWith("data:")) {
66+
return img;
67+
} else {
68+
let type = detectImageType(base64Decode(img));
69+
return `data:${type};base64,` + img;
70+
}
71+
} else {
72+
let bytes: Uint8Array | null = null;
73+
if (img.bytes && img.bytes instanceof Uint8Array) {
74+
bytes = img.bytes;
75+
}
76+
if (img instanceof Uint8Array) {
77+
bytes = img;
78+
}
79+
if (bytes != null) {
80+
let type = detectImageType(bytes);
81+
return `data:${type};base64,` + base64Encode(bytes);
82+
}
83+
}
84+
return null;
85+
}
86+
</script>
87+
88+
<img
89+
bind:this={element}
90+
onload={() => {
91+
if (element) {
92+
height = element.scrollHeight;
93+
}
94+
}}
95+
src={imageToDataUrl(image)}
96+
alt=""
97+
/>

packages/viewer/src/lib/CategoryLegend.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
});
9696
</script>
9797

98-
<div class="absolute right-0 top-0 p-2 m-2 rounded-md bg-opacity-90 bg-slate-100 dark:bg-slate-800">
98+
<div class="absolute right-0 top-0 p-2 m-2 rounded-md bg-slate-100/75 dark:bg-slate-800/75 backdrop-blur-sm">
9999
<table>
100100
<tbody>
101101
{#each items as item}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!-- Copyright (c) 2025 Apple Inc. Licensed under MIT License. -->
2+
<script lang="ts">
3+
import ColumnStylePickerRow from "./ColumnStylePickerRow.svelte";
4+
5+
import type { ColumnDesc } from "./database_utils.js";
6+
import { type ColumnStyle } from "./renderers/index.js";
7+
8+
interface Props {
9+
columns: ColumnDesc[];
10+
styles: Record<string, ColumnStyle>;
11+
onStylesChange: (value: Record<string, ColumnStyle>) => void;
12+
}
13+
14+
let { columns, styles, onStylesChange }: Props = $props();
15+
</script>
16+
17+
<div
18+
class="max-h-60 overflow-x-hidden overflow-y-scroll border border-slate-200 dark:border-slate-600 p-2 rounded-md inset-shadow-sm"
19+
>
20+
<table>
21+
<thead>
22+
<tr class="select-none">
23+
<th class="pb-2 text-slate-500 dark:text-slate-400 text-left font-normal text-sm">Column</th>
24+
<th class="pb-2 text-slate-500 dark:text-slate-400 text-left font-normal text-sm">Format</th>
25+
<th class="pb-2 text-slate-500 dark:text-slate-400 text-left font-normal text-sm">Style</th>
26+
</tr>
27+
</thead>
28+
<tbody>
29+
{#each columns as column}
30+
<ColumnStylePickerRow
31+
column={column}
32+
style={styles[column.name] ?? {}}
33+
onChange={(s) => {
34+
let newStyles = { ...styles };
35+
newStyles[column.name] = s;
36+
onStylesChange(newStyles);
37+
}}
38+
/>
39+
{/each}
40+
</tbody>
41+
</table>
42+
</div>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<!-- Copyright (c) 2025 Apple Inc. Licensed under MIT License. -->
2+
<script lang="ts">
3+
import Select from "./widgets/Select.svelte";
4+
import Slider from "./widgets/Slider.svelte";
5+
6+
import type { ColumnDesc } from "./database_utils.js";
7+
import { renderersList, type ColumnStyle } from "./renderers/index.js";
8+
9+
interface Props {
10+
column: ColumnDesc;
11+
style: ColumnStyle;
12+
onChange: (value: ColumnStyle) => void;
13+
}
14+
15+
let { column, style, onChange }: Props = $props();
16+
17+
function change(fields: Partial<ColumnStyle>) {
18+
onChange({ ...style, ...fields });
19+
}
20+
</script>
21+
22+
<tr class="leading-10">
23+
<td class="w-full">
24+
<div class="max-w-80 whitespace-nowrap text-ellipsis overflow-x-hidden">
25+
{column.name}
26+
</div>
27+
</td>
28+
<td class="pr-2">
29+
<div class="flex items-center gap-2">
30+
<Select
31+
value={style.renderer ?? null}
32+
onChange={(v) => change({ renderer: v })}
33+
options={[
34+
{ value: null, label: "(default)" },
35+
...renderersList.map((x) => ({ value: x.renderer, label: x.label })),
36+
]}
37+
/>
38+
39+
{#if style.renderer == "image"}
40+
<Slider
41+
bind:value={
42+
() => style.rendererOptions?.size ?? 100,
43+
(v) => {
44+
change({ rendererOptions: { size: v } });
45+
}
46+
}
47+
width={72}
48+
min={16}
49+
max={400}
50+
/>
51+
{/if}
52+
</div>
53+
</td>
54+
<td>
55+
<div class="flex items-center gap-2">
56+
<Select
57+
value={style.display ?? "badge"}
58+
onChange={(v) => {
59+
change({ display: v });
60+
}}
61+
options={[
62+
{ value: "full", label: "Full" },
63+
{ value: "badge", label: "Badge" },
64+
{ value: "hidden", label: "Hidden" },
65+
]}
66+
/>
67+
</div>
68+
</td>
69+
</tr>

0 commit comments

Comments
 (0)