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:
cproudlock
2026-08-12 11:45:17 -04:00
parent 27f76ee964
commit 2693eb28d6
2 changed files with 286 additions and 273 deletions

View File

@@ -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);

View File

@@ -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>