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> <style scoped>
.dc-grid { .dc-grid {
display: grid; display: grid;
/* Four across on a wide screen, not five. A card holds a hostname, a machine /* Four across on a wide screen. A card holds a hostname, a machine number
number and a state on one line; at five columns those lines truncate on and a state on one line, so the track cannot go much below this without
the very rows that matter most. auto-fit rather than auto-fill so two truncating the rows that matter most - but 28rem only fits three once the
cards fill the width instead of leaving three empty tracks. */ sidebar takes its share at 1920, which is the common case here. auto-fit
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr)); 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; max-width: 120rem;
gap: 1rem; gap: 1rem;
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
align-items: start; 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 { .dc-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@@ -1,268 +1,270 @@
<template> <template>
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave"> <div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
<slot></slot> <slot></slot>
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="visible && hasPosition" v-if="visible && hasPosition"
class="map-tooltip" class="map-tooltip"
:style="tooltipStyle" :style="tooltipStyle"
ref="tooltipRef" ref="tooltipRef"
@mouseenter="onTooltipEnter" @mouseenter="onTooltipEnter"
@mouseleave="onTooltipLeave" @mouseleave="onTooltipLeave"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
> >
<div class="map-tooltip-content"> <div class="map-tooltip-content">
<div class="map-preview" ref="mapPreview"> <div class="map-preview" ref="mapPreview">
<div <div
class="map-transform" class="map-transform"
:style="transformStyle" :style="transformStyle"
> >
<img <img
:src="blueprintUrl" :src="blueprintUrl"
alt="Shop Floor Map" alt="Shop Floor Map"
class="map-image" class="map-image"
@load="onImageLoad" @load="onImageLoad"
/> />
<!-- Marker dot --> <!-- Marker dot -->
<div <div
class="marker-dot" class="marker-dot"
:style="markerStyle" :style="markerStyle"
></div> ></div>
</div> </div>
</div> </div>
<div class="map-tooltip-footer"> <div class="map-tooltip-footer">
<span class="coordinates">{{ left }}, {{ top }}</span> <span class="coordinates">{{ left }}, {{ top }}</span>
<span class="zoom-hint">Scroll to zoom</span> <span class="zoom-hint">Scroll to zoom</span>
</div> </div>
</div> </div>
</div> </div>
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, nextTick, watch } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme' import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
// Fetch this facility's blueprint + dimensions once; computeds below react // Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads. // when it loads.
loadMapConfig() loadMapConfig()
const props = defineProps({ const props = defineProps({
left: { type: Number, default: null }, left: { type: Number, default: null },
top: { type: Number, default: null }, top: { type: Number, default: null },
machineName: { type: String, default: '' } machineName: { type: String, default: '' }
}) })
const visible = ref(false) const visible = ref(false)
const tooltipRef = ref(null) const tooltipRef = ref(null)
const mapPreview = ref(null) const mapPreview = ref(null)
const tooltipPosition = ref({ x: 0, y: 0 }) const tooltipPosition = ref({ x: 0, y: 0 })
const isOverTooltip = ref(false) const isOverTooltip = ref(false)
const zoom = ref(1) const zoom = ref(1)
const imageLoaded = ref(false) const imageLoaded = ref(false)
const hasPosition = computed(() => { const hasPosition = computed(() => {
return props.left !== null && props.top !== null return props.left !== null && props.top !== null
}) })
const blueprintUrl = computed(() => { const blueprintUrl = computed(() => {
// Reading currentTheme keeps this reactive to theme changes. // Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value) return blueprintUrlFor(currentTheme.value)
}) })
// Calculate marker position as percentage of the facility blueprint size // Calculate marker position as percentage of the facility blueprint size
const markerX = computed(() => { const markerX = computed(() => {
return (props.left / mapConfig.width) * 100 return (props.left / mapConfig.width) * 100
}) })
const markerY = computed(() => { const markerY = computed(() => {
return (props.top / mapConfig.height) * 100 return (props.top / mapConfig.height) * 100
}) })
// Marker style with counter-scale to maintain constant size // Marker style with counter-scale to maintain constant size
const markerStyle = computed(() => ({ const markerStyle = computed(() => ({
left: markerX.value + '%', left: markerX.value + '%',
top: markerY.value + '%', top: markerY.value + '%',
transform: `translate(-50%, -50%) scale(${1 / zoom.value})` transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
})) }))
// Transform style that centers on the marker and zooms toward it // Transform style that centers on the marker and zooms toward it
const transformStyle = computed(() => { const transformStyle = computed(() => {
// Calculate translation to center the marker in the preview // Calculate translation to center the marker in the preview
const translateX = 50 - markerX.value const translateX = 50 - markerX.value
const translateY = 50 - markerY.value const translateY = 50 - markerY.value
return { return {
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`, transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
transformOrigin: `${markerX.value}% ${markerY.value}%` transformOrigin: `${markerX.value}% ${markerY.value}%`
} }
}) })
const tooltipStyle = computed(() => ({ const tooltipStyle = computed(() => ({
left: `${tooltipPosition.value.x}px`, left: `${tooltipPosition.value.x}px`,
top: `${tooltipPosition.value.y}px` top: `${tooltipPosition.value.y}px`
})) }))
function onImageLoad() { function onImageLoad() {
imageLoaded.value = true imageLoaded.value = true
} }
function showTooltip(event) { function showTooltip(event) {
if (!hasPosition.value) return if (!hasPosition.value) return
visible.value = true visible.value = true
zoom.value = 1 zoom.value = 1
const rect = event.target.getBoundingClientRect() const rect = event.target.getBoundingClientRect()
tooltipPosition.value = { tooltipPosition.value = {
x: rect.left + rect.width / 2, x: rect.left + rect.width / 2,
y: rect.bottom + 10 y: rect.bottom + 10
} }
nextTick(() => { nextTick(() => {
adjustPosition() adjustPosition()
}) })
} }
function onWrapperLeave() { function onWrapperLeave() {
// Small delay to allow moving to tooltip // Small delay to allow moving to tooltip
setTimeout(() => { setTimeout(() => {
if (!isOverTooltip.value) { if (!isOverTooltip.value) {
hideTooltip() hideTooltip()
} }
}, 100) }, 100)
} }
function onTooltipEnter() { function onTooltipEnter() {
isOverTooltip.value = true isOverTooltip.value = true
} }
function onTooltipLeave() { function onTooltipLeave() {
isOverTooltip.value = false isOverTooltip.value = false
hideTooltip() hideTooltip()
} }
function hideTooltip() { function hideTooltip() {
visible.value = false visible.value = false
zoom.value = 1 zoom.value = 1
} }
function onWheel(event) { function onWheel(event) {
const delta = event.deltaY > 0 ? -0.3 : 0.3 const delta = event.deltaY > 0 ? -0.3 : 0.3
const newZoom = Math.max(1, Math.min(8, zoom.value + delta)) const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
zoom.value = newZoom zoom.value = newZoom
} }
function adjustPosition() { function adjustPosition() {
if (!tooltipRef.value) return if (!tooltipRef.value) return
const tooltip = tooltipRef.value const tooltip = tooltipRef.value
const rect = tooltip.getBoundingClientRect() const rect = tooltip.getBoundingClientRect()
const viewportWidth = window.innerWidth const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight
if (rect.right > viewportWidth - 20) { if (rect.right > viewportWidth - 20) {
tooltipPosition.value.x -= (rect.right - viewportWidth + 20) tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
} }
if (rect.left < 20) { if (rect.left < 20) {
tooltipPosition.value.x += (20 - rect.left) tooltipPosition.value.x += (20 - rect.left)
} }
if (rect.bottom > viewportHeight - 20) { if (rect.bottom > viewportHeight - 20) {
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20 tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
} }
} }
// Reset zoom when tooltip becomes visible // Reset zoom when tooltip becomes visible
watch(visible, (newVal) => { watch(visible, (newVal) => {
if (newVal) { if (newVal) {
zoom.value = 1 zoom.value = 1
} }
}) })
// Reset imageLoaded when theme changes to force reload // Reset imageLoaded when theme changes to force reload
watch(currentTheme, () => { watch(currentTheme, () => {
imageLoaded.value = false imageLoaded.value = false
}) })
</script> </script>
<style scoped> <style scoped>
.location-tooltip-wrapper { .location-tooltip-wrapper {
display: inline; display: inline;
cursor: pointer; cursor: pointer;
} }
.location-tooltip-wrapper:hover { .location-tooltip-wrapper:hover {
color: var(--primary, #1976d2); color: var(--primary, #1976d2);
} }
</style> </style>
<style> <style>
.map-tooltip { .map-tooltip {
position: fixed; position: fixed;
z-index: 10000; z-index: 10000;
transform: translateX(-50%); transform: translateX(-50%);
} }
.map-tooltip-content { .map-tooltip-content {
background: var(--bg-card, #ffffff); background: var(--bg-card, #ffffff);
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border, #e0e0e0); border: 1px solid var(--border, #e0e0e0);
box-shadow: 0 4px 20px rgba(0,0,0,0.25); box-shadow: 0 4px 20px rgba(0,0,0,0.25);
overflow: hidden; overflow: hidden;
} }
.map-preview { .map-preview {
position: relative; position: relative;
width: 500px; /* Smaller than it was: this is a glance-and-move-on preview, and at 500px it
height: 385px; covered the row it was launched from. Aspect ratio kept. */
overflow: hidden; width: 390px;
background: var(--bg, #f5f5f5); height: 300px;
} overflow: hidden;
background: var(--bg, #f5f5f5);
.map-transform { }
position: relative;
width: 100%; .map-transform {
height: 100%; position: relative;
transition: transform 0.15s ease-out; width: 100%;
} height: 100%;
transition: transform 0.15s ease-out;
.map-image { }
width: 100%;
height: 100%; .map-image {
object-fit: contain; width: 100%;
} height: 100%;
object-fit: contain;
.marker-dot { }
position: absolute;
width: 16px; .marker-dot {
height: 16px; position: absolute;
background: #ff0000; width: 16px;
border: 2px solid #ffffff; height: 16px;
border-radius: 50%; background: #ff0000;
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000; border: 2px solid #ffffff;
pointer-events: none; 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); .map-tooltip-footer {
border-top: 1px solid var(--border, #e0e0e0); padding: 0.5rem 0.75rem;
display: flex; background: var(--bg, #f5f5f5);
justify-content: space-between; border-top: 1px solid var(--border, #e0e0e0);
align-items: center; display: flex;
} justify-content: space-between;
align-items: center;
.coordinates { }
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem; .coordinates {
color: var(--text-light, #666666); 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); .zoom-hint {
} font-size: 0.75rem;
</style> color: var(--text-light, #666666);
}
</style>