Files
shopdb-flask/plugins/warranty/frontend/components/MachineMapChip.vue
cproudlock 4b1a64d298
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
warranty: stop the machine map preview being clipped on the last row
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.
2026-08-10 09:59:30 -04:00

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>