Files
shopdb-flask/frontend/src/components/LocationMapTooltip.vue

354 lines
10 KiB
Vue

<template>
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
<slot></slot>
<Teleport to="body">
<div
v-if="visible && hasPosition"
class="map-tooltip"
:style="tooltipStyle"
ref="tooltipRef"
@mouseenter="onTooltipEnter"
@mouseleave="onTooltipLeave"
@wheel.prevent="onWheel"
>
<div class="map-tooltip-content">
<div v-if="levelUnknown" class="map-level-unknown">
<strong>Level unknown</strong>
<span>
This asset has a position ({{ props.left }}, {{ props.top }}) but no
level, so there is no drawing to show it on. Set its level on the
asset, or place it in the map editor.
</span>
</div>
<div v-else class="map-preview" ref="mapPreview">
<div
class="map-transform"
:style="transformStyle"
>
<img
:src="blueprintUrl"
alt="Shop Floor Map"
class="map-image"
@load="onImageLoad"
/>
<!-- Marker dot -->
<div
class="marker-dot"
:style="markerStyle"
></div>
</div>
</div>
<div class="map-tooltip-footer">
<span class="coordinates">{{ left }}, {{ top }}</span>
<span class="zoom-hint">Scroll to zoom</span>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, levelName, state as mapConfig } from '../composables/mapConfig'
// Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads.
loadMapConfig()
const props = defineProps({
left: { type: Number, default: null },
top: { type: Number, default: null },
machineName: { type: String, default: '' },
// Which drawing left/top are pixels of (ADR-017). A position without one
// cannot be rendered: the same coordinates land somewhere different on every
// level, so this shows what is missing instead of guessing the default.
levelid: { type: Number, default: null },
})
const visible = ref(false)
const tooltipRef = ref(null)
const mapPreview = ref(null)
const tooltipPosition = ref({ x: 0, y: 0 })
const isOverTooltip = ref(false)
const zoom = ref(1)
const imageLoaded = ref(false)
const hasPosition = computed(() => {
return props.left !== null && props.top !== null
})
// A position we cannot place: coordinates but no level, or a level this instance
// does not know. Rendering the default blueprint here would look correct and be
// wrong, so the tooltip says so instead.
const levelUnknown = computed(() => {
return hasPosition.value && !hasLevel(props.levelid)
})
const levelLabel = computed(() => levelName(props.levelid))
const blueprintUrl = computed(() => {
// Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value, props.levelid)
})
// Marker position as a percentage of THIS LEVEL's native size. Percentages of
// the wrong level's dimensions is precisely how a marker ends up plausibly
// placed and wrong.
const markerX = computed(() => {
const fraction = props.left / dimensionsFor(props.levelid).width
const { offsetX, scaleX } = paintedBox.value
return (offsetX + fraction * scaleX) * 100
})
const markerY = computed(() => {
const fraction = props.top / dimensionsFor(props.levelid).height
const { offsetY, scaleY } = paintedBox.value
return (offsetY + fraction * scaleY) * 100
})
// Marker style with counter-scale to maintain constant size
const markerStyle = computed(() => ({
left: markerX.value + '%',
top: markerY.value + '%',
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
}))
// The frame the marker's percentages are measured against MUST be the frame the
// drawing is painted in. The image used object-fit: contain inside a fixed
// 390x300 box, so a level whose drawing is not that aspect ratio was letterboxed
// or pillarboxed - and the marker, positioned at a percentage of the BOX, then
// sat in a different coordinate space from the drawing inside it. A tall
// drawing pillarboxes, every marker's x shifts, and one near the left edge
// lands in the grey gutter beside the map. Levels near 390:300 looked correct,
// which is why it only affected some assets.
//
// The preview is a fixed box and object-fit: contain paints the drawing inside it
// at the drawing's own aspect ratio, so the drawing does not fill the box: it is
// letterboxed on one axis. A marker placed at a percentage of the BOX therefore
// lands somewhere else than the same percentage of the DRAWING, on any level
// whose shape is not the box's.
//
// So convert: work out where the painted drawing sits inside the box, then map
// the marker into box coordinates. Doing it here rather than by resizing the
// frame keeps the centring transform below honest - its percentages resolve
// against the element, so the element has to stay the size of the box.
const PREVIEW_WIDTH = 390 // keep in step with .map-preview in the styles
const PREVIEW_HEIGHT = 300
const PREVIEW_ASPECT = PREVIEW_WIDTH / PREVIEW_HEIGHT
// Fraction of the box the drawing covers, and the gap before it, per axis.
const paintedBox = computed(() => {
const { width, height } = dimensionsFor(props.levelid)
const levelAspect = width / height
if (levelAspect >= PREVIEW_ASPECT) {
// Wider than the box: full width, bars above and below.
const scale = PREVIEW_ASPECT / levelAspect
return { scaleX: 1, offsetX: 0, scaleY: scale, offsetY: (1 - scale) / 2 }
}
// Taller than the box: full height, bars left and right.
const scale = levelAspect / PREVIEW_ASPECT
return { scaleX: scale, offsetX: (1 - scale) / 2, scaleY: 1, offsetY: 0 }
})
// Transform style that centers on the marker and zooms toward it
const transformStyle = computed(() => {
// Calculate translation to center the marker in the preview
const translateX = 50 - markerX.value
const translateY = 50 - markerY.value
return {
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
transformOrigin: `${markerX.value}% ${markerY.value}%`
}
})
const tooltipStyle = computed(() => ({
left: `${tooltipPosition.value.x}px`,
top: `${tooltipPosition.value.y}px`
}))
function onImageLoad() {
imageLoaded.value = true
}
function showTooltip(event) {
if (!hasPosition.value) return
visible.value = true
zoom.value = 1
const rect = event.target.getBoundingClientRect()
tooltipPosition.value = {
x: rect.left + rect.width / 2,
y: rect.bottom + 10
}
nextTick(() => {
adjustPosition()
})
}
function onWrapperLeave() {
// Small delay to allow moving to tooltip
setTimeout(() => {
if (!isOverTooltip.value) {
hideTooltip()
}
}, 100)
}
function onTooltipEnter() {
isOverTooltip.value = true
}
function onTooltipLeave() {
isOverTooltip.value = false
hideTooltip()
}
function hideTooltip() {
visible.value = false
zoom.value = 1
}
function onWheel(event) {
const delta = event.deltaY > 0 ? -0.3 : 0.3
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
zoom.value = newZoom
}
function adjustPosition() {
if (!tooltipRef.value) return
const tooltip = tooltipRef.value
const rect = tooltip.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
if (rect.right > viewportWidth - 20) {
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
}
if (rect.left < 20) {
tooltipPosition.value.x += (20 - rect.left)
}
if (rect.bottom > viewportHeight - 20) {
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
}
}
// Reset zoom when tooltip becomes visible
watch(visible, (newVal) => {
if (newVal) {
zoom.value = 1
}
})
// Reset imageLoaded when theme changes to force reload
watch(currentTheme, () => {
imageLoaded.value = false
})
</script>
<style scoped>
.location-tooltip-wrapper {
display: inline;
cursor: pointer;
}
.location-tooltip-wrapper:hover {
color: var(--primary, #1976d2);
}
.map-level-unknown {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem;
max-width: 18rem;
font-size: 0.8rem;
color: var(--text-light);
}
.map-level-unknown strong {
color: var(--warning);
}
</style>
<style>
.map-tooltip {
position: fixed;
z-index: 10000;
transform: translateX(-50%);
}
.map-tooltip-content {
background: var(--bg-card, #ffffff);
border-radius: 8px;
border: 1px solid var(--border, #e0e0e0);
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
overflow: hidden;
}
.map-preview {
position: relative;
/* Smaller than it was: this is a glance-and-move-on preview, and at 500px it
covered the row it was launched from. Aspect ratio kept. */
width: 390px;
height: 300px;
overflow: hidden;
background: var(--bg, #f5f5f5);
}
.map-transform {
position: relative;
/* Fills the preview. The centring transform translates by percentages, which
resolve against THIS element, so it must stay the size of the box it is
centring within. The letterbox is accounted for in the marker maths. */
width: 100%;
height: 100%;
transition: transform 0.15s ease-out;
}
.map-image {
width: 100%;
height: 100%;
/* contain, so a level of any shape is shown whole and undistorted. Where the
bars fall is computed in paintedBox and folded into the marker position. */
object-fit: contain;
display: block;
}
.marker-dot {
position: absolute;
width: 16px;
height: 16px;
background: #ff0000;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
pointer-events: none;
}
.map-tooltip-footer {
padding: 0.5rem 0.75rem;
background: var(--bg, #f5f5f5);
border-top: 1px solid var(--border, #e0e0e0);
display: flex;
justify-content: space-between;
align-items: center;
}
.coordinates {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem;
color: var(--text-light, #666666);
}
.zoom-hint {
font-size: 0.75rem;
color: var(--text-light, #666666);
}
</style>