Cap the dashboard at four cards, and shrink the hover map
The card track was 28rem, which fits four across a full-width page but only three once the sidebar takes its 250px - and 1920 with the sidebar is the common case here, so the board showed three. The track is 22rem now, with an explicit four-column cap above 96rem: left to auto-fit alone a wide screen reaches five, and a fifth column only makes the cards narrower until the rows they hold start truncating again. Measured at 1366, 1600, 1920 and 2560: three, four, four, four. The floor-plan preview drops from 500x385 to 390x300. At the old size it covered the row it was launched from, which is the row you are trying to read.
This commit is contained in:
@@ -120,16 +120,27 @@ defineExpose({ load })
|
||||
<style scoped>
|
||||
.dc-grid {
|
||||
display: grid;
|
||||
/* Four across on a wide screen, not five. A card holds a hostname, a machine
|
||||
number and a state on one line; at five columns those lines truncate on
|
||||
the very rows that matter most. auto-fit rather than auto-fill so two
|
||||
cards fill the width instead of leaving three empty tracks. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
|
||||
/* Four across on a wide screen. A card holds a hostname, a machine number
|
||||
and a state on one line, so the track cannot go much below this without
|
||||
truncating the rows that matter most - but 28rem only fits three once the
|
||||
sidebar takes its share at 1920, which is the common case here. auto-fit
|
||||
rather than auto-fill so two cards fill the width instead of leaving
|
||||
empty tracks. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
max-width: 120rem;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
|
||||
alone this reaches five on an ultrawide, and a fifth column buys nothing -
|
||||
the cards get narrower and the rows they hold start truncating again. */
|
||||
@media (min-width: 96rem) {
|
||||
.dc-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.dc-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -1,268 +1,270 @@
|
||||
<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 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, 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: '' }
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
// Reading currentTheme keeps this reactive to theme changes.
|
||||
return blueprintUrlFor(currentTheme.value)
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage of the facility blueprint size
|
||||
const markerX = computed(() => {
|
||||
return (props.left / mapConfig.width) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / mapConfig.height) * 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})`
|
||||
}))
|
||||
|
||||
// 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);
|
||||
}
|
||||
</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;
|
||||
width: 500px;
|
||||
height: 385px;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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>
|
||||
<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 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, 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: '' }
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
// Reading currentTheme keeps this reactive to theme changes.
|
||||
return blueprintUrlFor(currentTheme.value)
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage of the facility blueprint size
|
||||
const markerX = computed(() => {
|
||||
return (props.left / mapConfig.width) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / mapConfig.height) * 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})`
|
||||
}))
|
||||
|
||||
// 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);
|
||||
}
|
||||
</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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user