diff --git a/packages/client/src/views/hermes/JourneyView.vue b/packages/client/src/views/hermes/JourneyView.vue index 7e2ee2c..856eeae 100644 --- a/packages/client/src/views/hermes/JourneyView.vue +++ b/packages/client/src/views/hermes/JourneyView.vue @@ -50,6 +50,7 @@ const canvasRef = ref(null) const canvasWrapRef = ref(null) const selectedId = ref('') const hoverId = ref('') +const hoverPosition = ref({ x: 0, y: 0 }) const hoverCategory = ref('') const playing = ref(false) const playbackIndex = ref(-1) @@ -77,6 +78,15 @@ const activePointers = new Map() const nodes = computed(() => data.value?.graph.nodes || []) const edges = computed(() => data.value?.graph.edges || []) 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(() => nodes.value .map((node, index) => ({ node, index })) @@ -467,6 +477,7 @@ function pointerPosition(event: MouseEvent | PointerEvent | WheelEvent): { x: nu } function handlePointerDown(event: PointerEvent) { + hoverId.value = '' activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (activePointers.size === 2) { const pointers = [...activePointers.values()] @@ -485,9 +496,13 @@ function handlePointerMove(event: PointerEvent) { if (activePointers.has(event.pointerId)) { activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }) } - const pos = pointerPosition(event) - const hit = hitTest(pos.x, pos.y) - hoverId.value = hit?.id || '' + if (!dragging && activePointers.size === 0 && event.pointerType !== 'touch') { + const pos = pointerPosition(event) + hoverId.value = hitTest(pos.x, pos.y)?.id || '' + hoverPosition.value = pos + } else { + hoverId.value = '' + } if (activePointers.size >= 2) { const pointers = [...activePointers.values()] @@ -536,6 +551,7 @@ function handleClick(event: MouseEvent) { function handleWheel(event: WheelEvent) { event.preventDefault() + hoverId.value = '' 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" @pointerup="handlePointerUp" @pointercancel="handlePointerUp" + @pointerleave="hoverId = ''" @click="handleClick" @wheel="handleWheel" /> +
{{ data?.profile || '-' }} {{ visibleSceneNodes.length }} {{ t('journey.nodes') }} @@ -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 { position: absolute; left: 16px; diff --git a/tests/e2e/journey-hover.spec.ts b/tests/e2e/journey-hover.spec.ts new file mode 100644 index 0000000..d4759ae --- /dev/null +++ b/tests/e2e/journey-hover.spec.ts @@ -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) + }) +}