web: node tooltip with cross-panel echo, slice 9 of M3

Each node carries a transparent 12 px hit halo. Hovering shows the
single tooltip instance near the pointer: student number, state in that
panel at the current round (origin / forwarded in round n / educated,
refused / not reached), and the student's friend count from the real
edges. The same student simultaneously gets a 2 px ink echo ring in
every panel: the same kid, reached in one world, safe in another.

Dismissal via pointerleave, tap elsewhere, or Esc. A touch tap on a
node shows the tooltip without opening the focus modal; a mouse click
still falls through to the zoom button.
This commit is contained in:
Justin Visser 2026-06-10 18:03:42 +02:00
parent 8929eeddb0
commit e0ecc5c6ba
4 changed files with 178 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import FocusModal from '@/components/FocusModal.vue'
import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
import HeroHeadline from '@/components/HeroHeadline.vue'
import LegendRow from '@/components/LegendRow.vue'
import NodeTooltip from '@/components/NodeTooltip.vue'
import PanelGrid from '@/components/PanelGrid.vue'
import PlayerBar from '@/components/PlayerBar.vue'
import ReachChart from '@/components/ReachChart.vue'
@ -47,6 +48,7 @@ onMounted(async () => {
</main>
<FooterDisclaimer />
<FocusModal v-if="store.state.focusPanelId" />
<NodeTooltip />
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { layoutForGraph, NETWORK_VIEW_HEIGHT, NETWORK_VIEW_WIDTH } from '@/composables/useLayout'
import { useNodeHover } from '@/composables/useNodeHover'
import { ROUND_MS } from '@/composables/usePlayback'
import { useSimStore } from '@/composables/useSimStore'
import { graphKey } from '@/lib/graph'
@ -14,6 +15,7 @@ import type { PanelSpec } from '@/presets/types'
const props = defineProps<{ panel: PanelSpec }>()
const store = useSimStore()
const { setHoveredNode, moveHoveredNode, clearHoveredNode } = useNodeHover()
const effectiveConfig = computed(() => store.effectiveConfig(props.panel))
const panelGraphKey = computed(() => graphKey(effectiveConfig.value))
@ -52,6 +54,21 @@ function edgeEnd(edge: number[], side: 0 | 1) {
return layout.value[edge[side] ?? 0] ?? { x: 0, y: 0 }
}
// The cross-panel echo (spec 5.5): the hovered student gets an ink ring
// in every panel simultaneously, regardless of which panel is pointed at.
const echoPoint = computed(() =>
store.state.hoveredNode !== null ? layout.value[store.state.hoveredNode] : undefined,
)
// A tap shows the tooltip without opening the focus modal; a mouse click
// falls through to the surrounding zoom button (spec 5.5 vs 5.8).
function onNodeClick(nodeIndex: number, event: MouseEvent) {
if (event instanceof PointerEvent && event.pointerType === 'touch') {
event.stopPropagation()
setHoveredNode(props.panel.id, nodeIndex, event)
}
}
// The fade-in spans the whole round interval (scaled with playback speed)
// and each node starts at a small deterministic offset, so during playback
// something is always in motion; there is never a finished still frame
@ -116,6 +133,29 @@ function popDelay(nodeIndex: number): string {
/>
<circle v-else :cx="node.x" :cy="node.y" r="3" fill="var(--unreached)" />
</g>
<circle
v-if="echoPoint"
class="echo"
:cx="echoPoint.x"
:cy="echoPoint.y"
r="6.5"
fill="none"
stroke="var(--ink)"
stroke-width="2"
/>
<!-- Transparent hit halos keep 4 px dots hoverable and tappable. -->
<circle
v-for="(point, nodeIndex) in layout"
:key="`hit-${nodeIndex}`"
:cx="point.x"
:cy="point.y"
r="12"
fill="transparent"
@pointerenter="setHoveredNode(panel.id, nodeIndex, $event)"
@pointermove="moveHoveredNode($event)"
@pointerleave="clearHoveredNode()"
@click="onNodeClick(nodeIndex, $event)"
/>
</svg>
</template>
@ -140,6 +180,10 @@ function popDelay(nodeIndex: number): string {
}
}
.echo {
pointer-events: none;
}
/* Nodes reached this round fade and scale in across the full round
interval (driven by --pop-ms), so playback reads as continuous motion.
The global reduced-motion rule collapses this to a discrete swap. */

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useNodeHover } from '@/composables/useNodeHover'
import { useSimStore } from '@/composables/useSimStore'
import { graphKey } from '@/lib/graph'
// The single tooltip instance (spec 5.5): student number, state in the
// hovered panel at the current round, and the student's degree. Pointer
// only; the equivalent information lives in the ResultsTable. Dismissed
// by pointerleave (NetworkView), tap elsewhere, or Esc.
const store = useSimStore()
const { hoverPosition, clearHoveredNode } = useNodeHover()
const hoveredNode = computed(() => store.state.hoveredNode)
const panel = computed(() =>
store.state.panels.find((candidate) => candidate.id === hoverPosition.panelId),
)
const active = computed(() => hoveredNode.value !== null && panel.value !== undefined)
const stateText = computed(() => {
const node = hoveredNode.value
const hoveredPanel = panel.value
if (node === null || !hoveredPanel) return ''
const config = store.effectiveConfig(hoveredPanel)
const result = store.state.resultsByPanelId[hoveredPanel.id]
if (node === config.origin) return 'Origin, posted it'
if (!result) return ''
const reachedAt = result.reachedAtRound[node] ?? -1
const reachedNow = reachedAt >= 0 && reachedAt <= store.state.round
if (reachedNow) return `Forwarded in round ${reachedAt}`
if (result.educated.includes(node)) return 'Educated, refused'
return 'Not reached'
})
const friendsText = computed(() => {
const node = hoveredNode.value
const hoveredPanel = panel.value
if (node === null || !hoveredPanel) return ''
const edges = store.state.edgesByGraphHash[graphKey(store.effectiveConfig(hoveredPanel))] ?? []
const degree = edges.filter((edge) => edge[0] === node || edge[1] === node).length
return `${degree} ${degree === 1 ? 'friend' : 'friends'}`
})
const positionStyle = computed(() => ({
left: `${Math.min(hoverPosition.clientX + 14, window.innerWidth - 190)}px`,
top: `${Math.min(hoverPosition.clientY + 14, window.innerHeight - 90)}px`,
}))
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') clearHoveredNode()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport to="body">
<div v-if="active" class="tooltip" :style="positionStyle" aria-hidden="true">
<strong>Student {{ hoveredNode }}</strong>
<span>{{ stateText }}</span>
<span class="friends">{{ friendsText }}</span>
</div>
</Teleport>
</template>
<style scoped>
.tooltip {
position: fixed;
z-index: 50;
pointer-events: none;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 130px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-lift);
padding: 8px 11px;
font-size: 12.5px;
color: var(--ink-2);
}
strong {
font-size: 13px;
font-weight: 650;
color: var(--ink);
}
.friends {
color: var(--ink-4);
}
</style>

View file

@ -0,0 +1,37 @@
import { reactive } from 'vue'
import { useSimStore } from './useSimStore'
// Node hover state (spec 5.5). The hovered student index lives in the sim
// store so every panel can draw the cross-panel echo ring; the tooltip
// additionally needs which panel is being pointed at and where.
interface NodeHoverPosition {
panelId: string | null
clientX: number
clientY: number
}
const hoverPosition = reactive<NodeHoverPosition>({ panelId: null, clientX: 0, clientY: 0 })
export function useNodeHover() {
const store = useSimStore()
function setHoveredNode(panelId: string, nodeIndex: number, event: MouseEvent) {
store.state.hoveredNode = nodeIndex
hoverPosition.panelId = panelId
hoverPosition.clientX = event.clientX
hoverPosition.clientY = event.clientY
}
function moveHoveredNode(event: MouseEvent) {
hoverPosition.clientX = event.clientX
hoverPosition.clientY = event.clientY
}
function clearHoveredNode() {
store.state.hoveredNode = null
hoverPosition.panelId = null
}
return { hoverPosition, setHoveredNode, moveHoveredNode, clearHoveredNode }
}