The bottom row's preview was cut off behind the pagination controls. .table-container sets overflow-x: auto, and ANY non-visible overflow makes an element a clipping context, so the absolutely-positioned preview was cropped at the container's edge. The last row had nowhere to open into. The preview is now positioned fixed from the chip's bounding rect, which escapes the clip entirely, and flips ABOVE the chip when there is not room below. It also clamps horizontally, since the column sits well to the right on a wide table and the panel is nearly 300px. Height is measured from the rendered element rather than assumed: it depends on the blueprint's aspect ratio, which differs per site. That means one tick where the element exists but is unplaced, so it starts hidden and is revealed once positioned - otherwise it flashed in the corner of the screen. Also sets white-space: normal. The table sets nowrap for its cells, which the preview inherited and which ran the location line off the panel.
166 lines
5.8 KiB
Vue
166 lines
5.8 KiB
Vue
<template>
|
|
<span
|
|
v-if="machine"
|
|
class="mmc"
|
|
ref="hostEl"
|
|
@mouseenter="show"
|
|
@mouseleave="open = false"
|
|
>
|
|
<!-- Deliberately not a link. /machines/:id is keyed by machineid, not
|
|
assetid, and resolving one to the other here would make the warranty
|
|
plugin import the machines plugin (ADR-014). The number plus the map
|
|
is what someone needs to walk to the bay. -->
|
|
<span class="mmc-chip">{{ machine.machinenumber || machine.name || '-' }}</span>
|
|
|
|
<!-- Hover preview: where to walk to. Rendered only while hovered so a
|
|
table of 200 rows does not build 200 blueprints. -->
|
|
<span v-if="open && hasPosition" ref="popEl" class="mmc-pop" :style="popStyle">
|
|
<span class="mmc-pop-title">{{ machine.name || machine.machinenumber }}</span>
|
|
<span v-if="machine.locationname" class="mmc-pop-loc">{{ machine.locationname }}</span>
|
|
<span class="mmc-map">
|
|
<img :src="blueprint" alt="" class="mmc-map-img" />
|
|
<span class="mmc-marker" :style="markerStyle"></span>
|
|
</span>
|
|
</span>
|
|
|
|
<!-- A machine with a number but no map position still deserves the chip;
|
|
say why there is no picture rather than showing an empty box. -->
|
|
<span v-else-if="open" ref="popEl" class="mmc-pop mmc-pop-plain" :style="popStyle">
|
|
<span class="mmc-pop-title">{{ machine.name || machine.machinenumber }}</span>
|
|
<span class="mmc-pop-loc">{{ machine.locationname || 'Not placed on the floor map' }}</span>
|
|
</span>
|
|
</span>
|
|
<span v-else class="muted">-</span>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
|
import { state as mapState, loadMapConfig, blueprintUrlFor } from '@/composables/mapConfig'
|
|
|
|
const props = defineProps({
|
|
machine: { type: Object, default: null }
|
|
})
|
|
|
|
const open = ref(false)
|
|
const hostEl = ref(null)
|
|
const popEl = ref(null)
|
|
// Starts hidden: the first tick renders before show() can measure, and an
|
|
// unpositioned fixed element would flash in the corner of the screen.
|
|
const popStyle = ref({ visibility: 'hidden' })
|
|
|
|
onMounted(() => { loadMapConfig() })
|
|
|
|
// position: fixed, placed from the chip's rect. The table sits in a
|
|
// .table-container with overflow-x: auto, and ANY non-visible overflow clips
|
|
// absolutely-positioned children - which cut the last row's preview off behind
|
|
// the pagination. Fixed escapes that clip entirely.
|
|
async function show() {
|
|
popStyle.value = { visibility: 'hidden' }
|
|
open.value = true
|
|
await nextTick()
|
|
const host = hostEl.value
|
|
if (!host) return
|
|
const rect = host.getBoundingClientRect()
|
|
const gap = 6
|
|
// Measure the real popover rather than guessing: its height depends on the
|
|
// blueprint's aspect ratio, which differs per site.
|
|
const popHeight = popEl.value ? popEl.value.offsetHeight : 260
|
|
const popWidth = popEl.value ? popEl.value.offsetWidth : 288
|
|
|
|
// Flip above when there is not room below, so the bottom row stays readable.
|
|
const roomBelow = window.innerHeight - rect.bottom
|
|
const top = (roomBelow < popHeight + gap && rect.top > popHeight + gap)
|
|
? rect.top - popHeight - gap
|
|
: rect.bottom + gap
|
|
|
|
// Keep it on screen horizontally too - the column sits well right on a wide
|
|
// table and the preview is nearly 300px.
|
|
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popWidth - 8))
|
|
|
|
popStyle.value = {
|
|
position: 'fixed', top: `${top}px`, left: `${left}px`, visibility: 'visible'
|
|
}
|
|
}
|
|
|
|
const hasPosition = computed(() =>
|
|
props.machine && props.machine.mapx != null && props.machine.mapy != null)
|
|
|
|
// Follow the viewer's theme the same way the full map does, so the preview is
|
|
// not a white rectangle on a dark page.
|
|
const blueprint = computed(() => {
|
|
const dark = document.documentElement.dataset.theme === 'dark'
|
|
|| (!document.documentElement.dataset.theme
|
|
&& window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
return blueprintUrlFor(dark ? 'dark' : 'light')
|
|
})
|
|
|
|
// mapx/mapy are pixel coordinates in the blueprint's own space, so they have to
|
|
// be expressed as a percentage of its configured dimensions - the preview is a
|
|
// few hundred pixels wide, not the full plan.
|
|
const markerStyle = computed(() => {
|
|
if (!hasPosition.value) return {}
|
|
const width = mapState.width || 3300
|
|
const height = mapState.height || 2550
|
|
return {
|
|
left: `${(props.machine.mapx / width) * 100}%`,
|
|
top: `${(props.machine.mapy / height) * 100}%`
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mmc { position: relative; display: inline-block; }
|
|
.mmc-chip {
|
|
display: inline-block;
|
|
padding: 0.1rem 0.5rem;
|
|
border-radius: 999px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-size: 0.8rem;
|
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
|
text-decoration: none;
|
|
}
|
|
.mmc:hover .mmc-chip { border-color: var(--primary); }
|
|
|
|
.mmc-pop {
|
|
/* Placement is computed in show(); these are only the fallback for the tick
|
|
before it runs. */
|
|
position: fixed;
|
|
z-index: 1000;
|
|
display: flex;
|
|
/* .table-container sets white-space: nowrap for the table; the preview text
|
|
must not inherit it or the location line runs off the panel. */
|
|
white-space: normal;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
padding: 0.5rem;
|
|
width: 18rem;
|
|
background: var(--bg-card-solid);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
|
|
}
|
|
.mmc-pop-plain { width: 14rem; }
|
|
.mmc-pop-title { font-weight: 600; font-size: 0.85rem; color: var(--text); }
|
|
.mmc-pop-loc { font-size: 0.78rem; color: var(--text-light); }
|
|
|
|
.mmc-map { position: relative; display: block; line-height: 0; }
|
|
.mmc-map-img {
|
|
width: 100%;
|
|
height: auto;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.mmc-marker {
|
|
position: absolute;
|
|
width: 12px;
|
|
height: 12px;
|
|
margin: -6px 0 0 -6px;
|
|
border-radius: 50%;
|
|
background: var(--danger);
|
|
border: 2px solid #fff;
|
|
box-shadow: 0 0 0 2px var(--danger);
|
|
}
|
|
</style>
|