diff --git a/frontend/src/components/ShopFloorMap.vue b/frontend/src/components/ShopFloorMap.vue index 5ab6411..6857d3d 100644 --- a/frontend/src/components/ShopFloorMap.vue +++ b/frontend/src/components/ShopFloorMap.vue @@ -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 diff --git a/frontend/src/utils/mapColors.js b/frontend/src/utils/mapColors.js index 230aedb..1913bd3 100644 --- a/frontend/src/utils/mapColors.js +++ b/frontend/src/utils/mapColors.js @@ -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) diff --git a/frontend/src/utils/mapColors.spec.js b/frontend/src/utils/mapColors.spec.js index 36b9233..c96e629 100644 --- a/frontend/src/utils/mapColors.spec.js +++ b/frontend/src/utils/mapColors.spec.js @@ -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 } } diff --git a/frontend/src/utils/mapPdf.js b/frontend/src/utils/mapPdf.js index 8601eac..175befe 100644 --- a/frontend/src/utils/mapPdf.js +++ b/frontend/src/utils/mapPdf.js @@ -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') }