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 FooterDisclaimer from '@/components/FooterDisclaimer.vue'
|
||||||
import HeroHeadline from '@/components/HeroHeadline.vue'
|
import HeroHeadline from '@/components/HeroHeadline.vue'
|
||||||
import LegendRow from '@/components/LegendRow.vue'
|
import LegendRow from '@/components/LegendRow.vue'
|
||||||
|
import NodeTooltip from '@/components/NodeTooltip.vue'
|
||||||
import PanelGrid from '@/components/PanelGrid.vue'
|
import PanelGrid from '@/components/PanelGrid.vue'
|
||||||
import PlayerBar from '@/components/PlayerBar.vue'
|
import PlayerBar from '@/components/PlayerBar.vue'
|
||||||
import ReachChart from '@/components/ReachChart.vue'
|
import ReachChart from '@/components/ReachChart.vue'
|
||||||
|
|
@ -47,6 +48,7 @@ onMounted(async () => {
|
||||||
</main>
|
</main>
|
||||||
<FooterDisclaimer />
|
<FooterDisclaimer />
|
||||||
<FocusModal v-if="store.state.focusPanelId" />
|
<FocusModal v-if="store.state.focusPanelId" />
|
||||||
|
<NodeTooltip />
|
||||||
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { layoutForGraph, NETWORK_VIEW_HEIGHT, NETWORK_VIEW_WIDTH } from '@/composables/useLayout'
|
import { layoutForGraph, NETWORK_VIEW_HEIGHT, NETWORK_VIEW_WIDTH } from '@/composables/useLayout'
|
||||||
|
import { useNodeHover } from '@/composables/useNodeHover'
|
||||||
import { ROUND_MS } from '@/composables/usePlayback'
|
import { ROUND_MS } from '@/composables/usePlayback'
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
import { graphKey } from '@/lib/graph'
|
import { graphKey } from '@/lib/graph'
|
||||||
|
|
@ -14,6 +15,7 @@ import type { PanelSpec } from '@/presets/types'
|
||||||
const props = defineProps<{ panel: PanelSpec }>()
|
const props = defineProps<{ panel: PanelSpec }>()
|
||||||
|
|
||||||
const store = useSimStore()
|
const store = useSimStore()
|
||||||
|
const { setHoveredNode, moveHoveredNode, clearHoveredNode } = useNodeHover()
|
||||||
|
|
||||||
const effectiveConfig = computed(() => store.effectiveConfig(props.panel))
|
const effectiveConfig = computed(() => store.effectiveConfig(props.panel))
|
||||||
const panelGraphKey = computed(() => graphKey(effectiveConfig.value))
|
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 }
|
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)
|
// The fade-in spans the whole round interval (scaled with playback speed)
|
||||||
// and each node starts at a small deterministic offset, so during playback
|
// and each node starts at a small deterministic offset, so during playback
|
||||||
// something is always in motion; there is never a finished still frame
|
// 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)" />
|
<circle v-else :cx="node.x" :cy="node.y" r="3" fill="var(--unreached)" />
|
||||||
</g>
|
</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>
|
</svg>
|
||||||
</template>
|
</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
|
/* Nodes reached this round fade and scale in across the full round
|
||||||
interval (driven by --pop-ms), so playback reads as continuous motion.
|
interval (driven by --pop-ms), so playback reads as continuous motion.
|
||||||
The global reduced-motion rule collapses this to a discrete swap. */
|
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