map marker legibility, PDF export blueprint fix, asset list type search

This commit is contained in:
2026-07-31 09:06:22 -04:00
parent a595eaffd5
commit 59578e1c5b
13 changed files with 458 additions and 60 deletions

View File

@@ -97,7 +97,7 @@ import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { getSubtypeId } from '../utils/mapColors'
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
import api from '../api'
const props = defineProps({
@@ -451,7 +451,7 @@ function renderMarkers() {
// Unified asset mode - use subtype colors when a type is selected
if (props.selectedAssetType) {
const subtypeId = getSubtypeId(item)
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
color = (subtypeId && props.subtypeColors[subtypeId]) || UNSPECIFIED_COLOR
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
} else {
// Prefer the stored AssetType.color; fall back to the built-in map.
@@ -477,7 +477,9 @@ function renderMarkers() {
const marker = L.circleMarker([leafletY, leafletX], {
radius: 6,
fillColor: color,
color: 'rgba(255,255,255,0.8)',
// Ring picked from the fill, not fixed white - a white ring on a light
// marker vanished against the light (white) blueprint.
color: markerRingColor(color),
weight: 2,
fillOpacity: 1,
renderer: canvasRenderer

View File

@@ -2,17 +2,27 @@
// the exact same colors the map shows on screen. Mirrors the maps defined in
// ShopFloorMap.vue - keep the two in sync if the palette changes.
// Markers sit on TWO very different surfaces: the light blueprint on white, and
// the dark blueprint on the near-black navy card. Every step below clears 3:1
// against both (#ffffff and the dark card, ~#060628), so no marker washes out on
// either. The hues keep their established meaning - only the step changed. The
// old values were far too light to survive white: orange #FF9800 sat at 2.16:1
// and the grey default at 1.88:1.
export const assetTypeColorsMap = {
machine: '#F44336', // Red
computer: '#2196F3', // Blue
printer: '#4CAF50', // Green
'network device': '#FF9800', // Orange
network_device: '#FF9800', // Orange (alternate key)
measuring_tool: '#9C27B0', // Purple
'measuring tool': '#9C27B0' // Purple (normalized key)
machine: '#e03131', // Red
computer: '#1976d2', // Blue
printer: '#2e7d32', // Green
'network device': '#e65100', // Orange
network_device: '#e65100', // Orange (alternate key)
measuring_tool: '#9c4dcc', // Purple
'measuring tool': '#9c4dcc' // Purple (normalized key)
}
const DEFAULT_COLOR = '#BDBDBD'
// Fallback for an asset with no subtype. Reads as neutral without vanishing:
// 5.4:1 on white, 3.7:1 on the dark card (the old #BDBDBD was 1.88:1 on white).
export const UNSPECIFIED_COLOR = '#546e7a'
const DEFAULT_COLOR = UNSPECIFIED_COLOR
// Canonical form for comparing asset-type strings. The API sends the machine
// type as 'network_device' but subtype keys and labels use 'network device',
@@ -43,8 +53,23 @@ export function getSubtypeId(asset) {
return null
}
// Ring color for a marker of the given fill. A fixed white ring disappears on
// the white blueprint, so pick the ring by the FILL's lightness instead: a light
// fill gets a dark ring, a dark fill a light one. The marker then keeps a hard
// edge on either surface, and it also rescues a washed-out color a user picked
// by hand for a subtype - which the palette below cannot control.
export function markerRingColor(fill) {
const hex = (fill || '').replace('#', '')
if (hex.length !== 6) return '#ffffff'
const [r, g, b] = [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
// Relative luminance (WCAG), same formula the palette validator uses.
const channel = c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
return luminance > 0.35 ? '#1b1b1b' : '#ffffff'
}
// Resolve the marker color for an asset the same way ShopFloorMap does: when a
// type is selected, color by subtype (grey fallback); otherwise by asset type.
// type is selected, color by subtype (neutral fallback); otherwise by asset type.
export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) {
if (selectedType) {
const id = getSubtypeId(asset)

View File

@@ -1,5 +1,26 @@
import { describe, it, expect } from 'vitest'
import { getSubtypeId, resolveMarkerColor } from './mapColors'
import {
getSubtypeId, resolveMarkerColor, markerRingColor, assetTypeColorsMap,
UNSPECIFIED_COLOR
} from './mapColors'
// WCAG relative luminance / contrast, so the palette assertions below are
// computed rather than eyeballed.
function contrast(a, b) {
const lum = hex => {
const h = hex.replace('#', '')
const [r, g, b2] = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
const ch = c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b2)
}
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x)
return (hi + 0.05) / (lo + 0.05)
}
// The two surfaces a marker has to survive: the light blueprint (white) and the
// dark blueprint over the dark card (--bg-card over --bg).
const WHITE = '#ffffff'
const DARK_CARD = '#060628'
// One case per asset type that carries a subtype. A missing branch here means
// the map subtype filter silently drops every asset of that type.
@@ -26,6 +47,34 @@ describe('getSubtypeId', () => {
})
})
describe('marker legibility', () => {
const palette = [...new Set(Object.values(assetTypeColorsMap)), UNSPECIFIED_COLOR]
it.each(palette)('%s clears 3:1 on the white blueprint', (color) => {
expect(contrast(color, WHITE)).toBeGreaterThanOrEqual(3)
})
it.each(palette)('%s clears 3:1 on the dark card', (color) => {
expect(contrast(color, DARK_CARD)).toBeGreaterThanOrEqual(3)
})
it('rings a light fill dark and a dark fill light', () => {
expect(markerRingColor('#FFEB3B')).toBe('#1b1b1b')
expect(markerRingColor('#1565c0')).toBe('#ffffff')
})
it('gives every ring 3:1 against its own fill', () => {
for (const color of palette) {
expect(contrast(color, markerRingColor(color))).toBeGreaterThanOrEqual(3)
}
})
it('falls back to a light ring for a malformed color', () => {
expect(markerRingColor('')).toBe('#ffffff')
expect(markerRingColor('nonsense')).toBe('#ffffff')
})
})
describe('resolveMarkerColor', () => {
it('colors a measuring tool by its subtype when a type is selected', () => {
const asset = { assettype: 'Measuring Tool', typedata: { measuringtooltypeid: 7 } }

View File

@@ -3,7 +3,10 @@
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
// vector-over-raster page rather than a screen capture.
import { jsPDF } from 'jspdf'
import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors'
import {
resolveMarkerColor, getSubtypeId, getAssetTypeColor, markerRingColor,
UNSPECIFIED_COLOR
} from './mapColors'
function loadImage(url) {
return new Promise((resolve, reject) => {
@@ -16,7 +19,7 @@ function loadImage(url) {
}
function hexToRgb(hex) {
const h = (hex || '#BDBDBD').replace('#', '')
const h = (hex || UNSPECIFIED_COLOR).replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
}
@@ -31,7 +34,7 @@ function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) {
const id = getSubtypeId(a)
key = id != null ? String(id) : 'none'
label = (id != null && subtypeNames[id]) || 'Unspecified'
color = (id != null && subtypeColors[id]) || '#BDBDBD'
color = (id != null && subtypeColors[id]) || UNSPECIFIED_COLOR
} else {
key = (a.assettype || 'unknown').toLowerCase()
label = a.assettype || 'Unknown'
@@ -94,8 +97,9 @@ export async function exportMapPdf(opts) {
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
const [r, g, b] = hexToRgb(entry.color)
doc.setFillColor(r, g, b)
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4)
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F')
// Outline the swatch against the white page, same rule as the markers.
doc.setDrawColor(markerRingColor(entry.color)); doc.setLineWidth(0.4)
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'FD')
doc.setTextColor('#333333')
doc.text(label, lx + swatch + 4, ly)
lx += w
@@ -124,9 +128,11 @@ export async function exportMapPdf(opts) {
const x = imgX + (a.mapx / mapWidth) * imgW
const y = imgY + (a.mapy / mapHeight) * imgH
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors }))
const fill = resolveMarkerColor(a, { selectedType, subtypeColors })
const [r, g, b] = hexToRgb(fill)
doc.setFillColor(r, g, b)
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6)
// The PDF prints on white, where a white ring is invisible - ring by fill.
doc.setDrawColor(markerRingColor(fill)); doc.setLineWidth(0.6)
doc.circle(x, y, radius, 'FD')
}

View File

@@ -82,7 +82,7 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { getSubtypeId } from '../utils/mapColors'
@@ -140,12 +140,18 @@ const subtypeLabel = computed(() => {
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Generate distinct colors for subtypes
// Auto colors for subtypes that have not been given one. Every step clears 3:1
// against BOTH map surfaces (white blueprint and the dark navy card), so none
// washes out; the old list ran through pale yellows and limes (#FFEB3B was
// 1.1:1 on white) that were invisible on the light blueprint. Ordered by
// max-min perceptual separation, so the fewer subtypes are on screen the
// further apart their colors sit. Past roughly the fifth entry no palette can
// keep every pair distinguishable under color-blind simulation - the legend and
// the hover tooltip carry identity from there.
const subtypeColorPalette = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
'#7c4dff', '#ef6c00', '#0097a7', '#c2185b', '#1565c0',
'#e03131', '#e0479e', '#00897b', '#827717', '#1976d2',
'#d81b60', '#388e3c', '#9c4dcc', '#2e7d32'
]
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
@@ -236,7 +242,10 @@ async function exportPdf() {
await loadMapConfig()
await exportMapPdf({
assets: filteredAssets.value,
blueprintUrl: mapConfig.blueprintLight,
// blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper.
blueprintUrl: blueprintUrlFor('light'),
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
selectedType: selectedType.value,