docling-studio/frontend/src/features/analysis/ui/BboxOverlay.vue
Pier-Jean Malandrino 1ed9552224 Dim non-highlighted bboxes in Verify mode for visual consistency
Apply the same dimming strategy used in Prepare mode: when an element
is highlighted, reduce stroke and fill opacity of all other bboxes
so the focused element stands out clearly.
2026-04-02 16:19:43 +02:00

295 lines
7.8 KiB
Vue

<template>
<div class="bbox-overlay">
<!-- Legend bar -->
<div class="overlay-legend" v-if="pageData">
<button
v-for="[type, color] in Object.entries(ELEMENT_COLORS)"
:key="type"
class="legend-chip"
:class="{ dimmed: hiddenTypes.has(type) }"
@click="toggleType(type)"
>
<span class="legend-dot" :style="{ background: color }" />
<span>{{ type }}</span>
<span class="legend-count">{{ countElements(type) }}</span>
</button>
</div>
<!-- Canvas + Tooltip (positioned over image via parent) -->
<canvas
ref="canvasRef"
class="overlay-canvas"
@mousemove="onMouseMove"
@mouseleave="hoveredElement = null; emit('highlight-element', -1)"
/>
<div
v-if="hoveredElement"
class="tooltip"
:style="tooltipStyle"
>
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
{{ hoveredElement.type }}
</span>
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
import type { Page, PageElement, ChunkBbox } from '../../../shared/types'
const ELEMENT_COLORS: Record<string, string> = {
title: '#EF4444',
section_header: '#F97316',
text: '#3B82F6',
table: '#8B5CF6',
picture: '#22C55E',
list: '#06B6D4',
formula: '#EC4899',
code: '#14B8A6',
caption: '#EAB308'
}
const props = withDefaults(
defineProps<{
imageEl: HTMLImageElement | null
pageData: Page | null
highlightedIndex: number
highlightedBboxes: ChunkBbox[]
}>(),
{ highlightedBboxes: () => [] },
)
const emit = defineEmits<{
'highlight-element': [index: number]
}>()
const hiddenTypes = reactive(new Set<string>())
const canvasRef = ref<HTMLCanvasElement | null>(null)
const hoveredElement = ref<PageElement | null>(null)
const tooltipStyle = ref<Record<string, string>>({})
const visibleElements = computed(() => {
if (!props.pageData) return []
return props.pageData.elements.filter((e: PageElement) => !hiddenTypes.has(e.type))
})
// Single source of truth for content-bearing elements.
// Used by both draw() and onMouseMove() to keep highlight indices in sync.
const contentElements = computed(() => {
if (!props.pageData) return []
return props.pageData.elements.filter((e: PageElement) => e.content)
})
function toggleType(type: string): void {
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
else hiddenTypes.add(type)
draw()
}
function countElements(type: string): number {
if (!props.pageData) return 0
return props.pageData.elements.filter((e: PageElement) => e.type === type).length
}
function draw(): void {
const canvas = canvasRef.value
const img = props.imageEl
if (!canvas || !img) return
if (!img.clientWidth || !img.clientHeight) return
// Retina support: scale the canvas backing store by devicePixelRatio
// so strokes render crisp on high-DPI screens.
const dpr = window.devicePixelRatio || 1
canvas.width = img.clientWidth * dpr
canvas.height = img.clientHeight * dpr
canvas.style.width = img.clientWidth + 'px'
canvas.style.height = img.clientHeight + 'px'
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, img.clientWidth, img.clientHeight)
if (!props.pageData) return
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
const hasHighlight = props.highlightedIndex >= 0
const hasChunkHighlight = props.highlightedBboxes.length > 0
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
const elContentIdx = contentElements.value.indexOf(el)
const isHighlighted = hasHighlight && elContentIdx === props.highlightedIndex
const dimmed = (hasHighlight || hasChunkHighlight) && !isHighlighted
ctx.strokeStyle = dimmed ? color + '40' : color
ctx.lineWidth = isHighlighted ? 3 : 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = color + (isHighlighted ? '40' : dimmed ? '08' : '20')
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
// Draw chunk-highlighted bboxes on top with accent color
if (hasChunkHighlight) {
const CHUNK_COLOR = '#F59E0B'
for (const cb of props.highlightedBboxes) {
const rect = bboxToRect(cb.bbox, scale)
ctx.strokeStyle = CHUNK_COLOR
ctx.lineWidth = 3
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = CHUNK_COLOR + '30'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
}
}
function onMouseMove(e: MouseEvent): void {
const canvas = canvasRef.value
const img = props.imageEl
if (!canvas || !img || !props.pageData) return
const canvasRect = canvas.getBoundingClientRect()
const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
let found: PageElement | null = null
let foundIdx = -1
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
found = el
foundIdx = contentElements.value.indexOf(el)
break
}
}
hoveredElement.value = found
emit('highlight-element', foundIdx)
if (found) {
tooltipStyle.value = {
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
top: `${my + 12}px`
}
}
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (props.imageEl) {
resizeObserver = new ResizeObserver(() => draw())
resizeObserver.observe(props.imageEl)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
// Redraw when data or image changes
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, () => props.highlightedBboxes, hiddenTypes], () => {
nextTick(draw)
})
// Expose draw so parent can call it after image load
defineExpose({ draw })
</script>
<style scoped>
.bbox-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.overlay-legend {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
z-index: 5;
pointer-events: auto;
}
.legend-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 10px;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
backdrop-filter: blur(8px);
opacity: 0.9;
}
.legend-chip:hover { opacity: 1; background: var(--bg-hover); }
.legend-chip.dimmed { opacity: 0.35; }
.legend-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-count {
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.overlay-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: auto;
/* No CSS width/height — dimensions set purely by canvas.width/height in JS
to avoid browser stretching the canvas content */
}
.tooltip {
position: absolute;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
max-width: 250px;
pointer-events: none;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.tooltip-type {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.tooltip-content {
display: block;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
word-break: break-word;
}
</style>