fix(journey): preview graph nodes on hover
This commit is contained in:
@@ -50,6 +50,7 @@ const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|||||||
const canvasWrapRef = ref<HTMLElement | null>(null)
|
const canvasWrapRef = ref<HTMLElement | null>(null)
|
||||||
const selectedId = ref('')
|
const selectedId = ref('')
|
||||||
const hoverId = ref('')
|
const hoverId = ref('')
|
||||||
|
const hoverPosition = ref({ x: 0, y: 0 })
|
||||||
const hoverCategory = ref('')
|
const hoverCategory = ref('')
|
||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const playbackIndex = ref(-1)
|
const playbackIndex = ref(-1)
|
||||||
@@ -77,6 +78,15 @@ const activePointers = new Map<number, { x: number; y: number }>()
|
|||||||
const nodes = computed(() => data.value?.graph.nodes || [])
|
const nodes = computed(() => data.value?.graph.nodes || [])
|
||||||
const edges = computed(() => data.value?.graph.edges || [])
|
const edges = computed(() => data.value?.graph.edges || [])
|
||||||
const selectedNode = computed(() => nodes.value.find(node => node.id === selectedId.value) || null)
|
const selectedNode = computed(() => nodes.value.find(node => node.id === selectedId.value) || null)
|
||||||
|
const hoverNode = computed(() => visibleSceneNodes.value.find(node => node.id === hoverId.value) || null)
|
||||||
|
const hoverPreviewStyle = computed(() => {
|
||||||
|
const wrap = canvasWrapRef.value
|
||||||
|
const maxWidth = Math.min(300, Math.max(0, (wrap?.clientWidth || 0) - 16))
|
||||||
|
return {
|
||||||
|
left: `${Math.max(8, Math.min(hoverPosition.value.x + 16, (wrap?.clientWidth || 0) - maxWidth - 8))}px`,
|
||||||
|
top: `${Math.max(8, Math.min(hoverPosition.value.y + 16, (wrap?.clientHeight || 0) - 168))}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
const playbackNodes = computed(() =>
|
const playbackNodes = computed(() =>
|
||||||
nodes.value
|
nodes.value
|
||||||
.map((node, index) => ({ node, index }))
|
.map((node, index) => ({ node, index }))
|
||||||
@@ -467,6 +477,7 @@ function pointerPosition(event: MouseEvent | PointerEvent | WheelEvent): { x: nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
hoverId.value = ''
|
||||||
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
if (activePointers.size === 2) {
|
if (activePointers.size === 2) {
|
||||||
const pointers = [...activePointers.values()]
|
const pointers = [...activePointers.values()]
|
||||||
@@ -485,9 +496,13 @@ function handlePointerMove(event: PointerEvent) {
|
|||||||
if (activePointers.has(event.pointerId)) {
|
if (activePointers.has(event.pointerId)) {
|
||||||
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
}
|
}
|
||||||
|
if (!dragging && activePointers.size === 0 && event.pointerType !== 'touch') {
|
||||||
const pos = pointerPosition(event)
|
const pos = pointerPosition(event)
|
||||||
const hit = hitTest(pos.x, pos.y)
|
hoverId.value = hitTest(pos.x, pos.y)?.id || ''
|
||||||
hoverId.value = hit?.id || ''
|
hoverPosition.value = pos
|
||||||
|
} else {
|
||||||
|
hoverId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
if (activePointers.size >= 2) {
|
if (activePointers.size >= 2) {
|
||||||
const pointers = [...activePointers.values()]
|
const pointers = [...activePointers.values()]
|
||||||
@@ -536,6 +551,7 @@ function handleClick(event: MouseEvent) {
|
|||||||
|
|
||||||
function handleWheel(event: WheelEvent) {
|
function handleWheel(event: WheelEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
hoverId.value = ''
|
||||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + (event.deltaY > 0 ? -0.08 : 0.08)))
|
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + (event.deltaY > 0 ? -0.08 : 0.08)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,9 +690,14 @@ watch(sceneNodes, () => {
|
|||||||
@pointermove="handlePointerMove"
|
@pointermove="handlePointerMove"
|
||||||
@pointerup="handlePointerUp"
|
@pointerup="handlePointerUp"
|
||||||
@pointercancel="handlePointerUp"
|
@pointercancel="handlePointerUp"
|
||||||
|
@pointerleave="hoverId = ''"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
@wheel="handleWheel"
|
@wheel="handleWheel"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="hoverNode" class="node-preview" :style="hoverPreviewStyle" role="tooltip">
|
||||||
|
<strong>{{ detailTitle(hoverNode) }}</strong>
|
||||||
|
<p v-if="isMemoryNode(hoverNode) && memoryText(hoverNode)">{{ memoryText(hoverNode) }}</p>
|
||||||
|
</div>
|
||||||
<div class="galaxy-hud">
|
<div class="galaxy-hud">
|
||||||
<span>{{ data?.profile || '-' }}</span>
|
<span>{{ data?.profile || '-' }}</span>
|
||||||
<span>{{ visibleSceneNodes.length }} {{ t('journey.nodes') }}</span>
|
<span>{{ visibleSceneNodes.length }} {{ t('journey.nodes') }}</span>
|
||||||
@@ -828,6 +849,35 @@ watch(sceneNodes, () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-preview {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(300px, calc(100% - 16px));
|
||||||
|
max-height: 152px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $bg-secondary;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||||
|
color: $text-primary;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
strong { display: block; }
|
||||||
|
p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.galaxy-hud {
|
.galaxy-hud {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||||
|
|
||||||
|
// The fixture has one category and one node; calculate its initial projection
|
||||||
|
// so the test exercises the actual canvas hit testing and pointer events.
|
||||||
|
function nodePosition(id: string, kind: 'memory' | 'skill', width: number, height: number) {
|
||||||
|
let hash = 2166136261
|
||||||
|
for (const char of `${id}:0`) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619)
|
||||||
|
const seed = hash >>> 0
|
||||||
|
const a = (seed % 6283) / 1000
|
||||||
|
const b = ((seed >> 8) % 3141) / 1000
|
||||||
|
const spread = kind === 'memory' ? 125 : 165
|
||||||
|
const x = Math.cos(-0.84) * 360 + Math.cos(a) * Math.sin(b) * spread
|
||||||
|
const y = Math.sin(-0.84) * 360 * 0.72 + Math.cos(b) * spread * 0.85
|
||||||
|
const z = Math.sin(a) * Math.sin(b) * spread
|
||||||
|
const x1 = x * Math.cos(0.55) - z * Math.sin(0.55)
|
||||||
|
const z1 = x * Math.sin(0.55) + z * Math.cos(0.55)
|
||||||
|
const y1 = y * Math.cos(-0.25) - z1 * Math.sin(-0.25)
|
||||||
|
const z2 = y * Math.sin(-0.25) + z1 * Math.cos(-0.25)
|
||||||
|
const perspective = 860 / (860 + z2)
|
||||||
|
return { x: width / 2 + x1 * perspective, y: height / 2 + y1 * perspective }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kind of ['memory', 'skill'] as const) {
|
||||||
|
test(`previews a ${kind} node on hover and keeps click details`, async ({ page }) => {
|
||||||
|
const id = kind === 'memory' ? 'memory:0' : 'skill:example'
|
||||||
|
const label = kind === 'memory' ? 'Memory label' : 'Example skill title'
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
|
await mockHermesApi(page)
|
||||||
|
await page.route(/^https?:\/\/[^/]+\/api\/hermes\/journey(?:\?.*)?$/, route => route.fulfill({ json: {
|
||||||
|
profile: 'research', source: 'cli', graph: {
|
||||||
|
nodes: [{ id, label, kind, category: 'general', useCount: 1 }], edges: [], clusters: [],
|
||||||
|
memory: [{ title: 'Memory label', body: 'Remember this useful detail.' }],
|
||||||
|
},
|
||||||
|
} }))
|
||||||
|
await page.goto('/#/hermes/journey')
|
||||||
|
const canvas = page.locator('.galaxy-canvas')
|
||||||
|
await expect(page.locator('.galaxy-hud')).toContainText('1')
|
||||||
|
const box = (await canvas.boundingBox())!
|
||||||
|
const point = nodePosition(id, kind, box.width, box.height)
|
||||||
|
await page.mouse.move(box.x + point.x, box.y + point.y)
|
||||||
|
const preview = page.locator('.node-preview')
|
||||||
|
await expect(preview).toBeVisible()
|
||||||
|
await expect(preview).toContainText(kind === 'memory' ? 'Remember this useful detail.' : label)
|
||||||
|
const previewBox = (await preview.boundingBox())!
|
||||||
|
expect(previewBox.x).toBeGreaterThanOrEqual(box.x)
|
||||||
|
expect(previewBox.x + previewBox.width).toBeLessThanOrEqual(box.x + box.width + 1)
|
||||||
|
await expect(page.locator('.journey-detail-drawer')).toBeHidden()
|
||||||
|
await page.mouse.click(box.x + point.x, box.y + point.y)
|
||||||
|
await expect(page.locator('.journey-detail-drawer')).toBeVisible()
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.mouse.move(box.x + 5, box.y + box.height - 5)
|
||||||
|
await expect(preview).toHaveCount(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user