warranty: use the shared LocationMapTooltip for the machine preview
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

The machine map preview was a bespoke popover. LocationMapTooltip already
existed and is what the machine detail page uses, so the warranty tables now
show the same thing.

That is what was asked for - the preview zooms, as it does on the machine page -
and it comes with behaviour the bespoke one did not have: scroll-wheel zoom, and
staying open while the pointer is on the tooltip itself so it can actually be
read and panned.

It also solves the clipping properly. The previous commit reached for
position: fixed with a hand-rolled flip because .table-container's overflow-x
clipped an absolute child; LocationMapTooltip teleports to body, which avoids
the clipping context altogether rather than escaping it. All of that
positioning code, and the blueprint rendering, is deleted - 64 lines from 140.

An unplaced machine keeps its chip and now says why in the native tooltip
rather than showing an empty panel: 17 of the 142 linked machines have no map
position, and those rows still need their number.
This commit is contained in:
cproudlock
2026-08-10 10:05:15 -04:00
parent 4b1a64d298
commit 2785c0463e

View File

@@ -1,115 +1,53 @@
<template>
<span
v-if="machine"
class="mmc"
ref="hostEl"
@mouseenter="show"
@mouseleave="open = false"
<!-- Placed machine: reuse the same hover map the machine detail page uses, so
the zoom, scroll-wheel behaviour and teleport-out-of-clipping-containers
are identical everywhere instead of reimplemented here. -->
<LocationMapTooltip
v-if="machine && hasPosition"
:left="machine.mapx"
:top="machine.mapy"
:machineName="machine.machinenumber || machine.name || ''"
>
<!-- 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 class="mmc-chip" :title="hoverTitle">
{{ machine.machinenumber || machine.name || '-' }}
</span>
</LocationMapTooltip>
<!-- 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>
<!-- Known machine, never placed on the floor map. Still worth its number:
against real data 17 of 142 linked machines have no position, and
dropping them would silently lose the column for those rows. -->
<span v-else-if="machine" class="mmc-chip" :title="hoverTitle">
{{ machine.machinenumber || machine.name || '-' }}
</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'
import { computed } from 'vue'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
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}%`
}
// The name and location are not in the chip (it has to stay narrow in a table
// column), so they go in the native tooltip. An unplaced machine says so, since
// otherwise hovering it does nothing and looks broken.
const hoverTitle = computed(() => {
if (!props.machine) return ''
const parts = []
if (props.machine.name) parts.push(props.machine.name)
if (props.machine.locationname) parts.push(props.machine.locationname)
if (!hasPosition.value) parts.push('not placed on the floor map')
return parts.join(' - ')
})
</script>
<style scoped>
.mmc { position: relative; display: inline-block; }
.mmc-chip {
display: inline-block;
padding: 0.1rem 0.5rem;
@@ -119,47 +57,8 @@ const markerStyle = computed(() => {
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);
white-space: nowrap;
cursor: default;
}
.mmc-chip:hover { border-color: var(--primary); }
</style>