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:
parent
8929eeddb0
commit
e0ecc5c6ba
4 changed files with 178 additions and 0 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
95
web/src/components/NodeTooltip.vue
Normal file
95
web/src/components/NodeTooltip.vue
Normal 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>
|
||||
37
web/src/composables/useNodeHover.ts
Normal file
37
web/src/composables/useNodeHover.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue