map marker rings keyed on surface, legend parity

This commit is contained in:
2026-07-31 09:29:49 -04:00
parent 59578e1c5b
commit d84f7e3911
4 changed files with 41 additions and 33 deletions

View File

@@ -38,7 +38,7 @@
:key="t.machinetypeid"
class="legend-item"
>
<span class="legend-dot" :style="{ background: getTypeColor(t.machinetype) }"></span>
<span class="legend-dot" :style="legendDotStyle(getTypeColor(t.machinetype))"></span>
{{ t.machinetype }}
</span>
</div>
@@ -53,7 +53,7 @@
:key="subtypeId"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
<span class="legend-dot" :style="legendDotStyle(color)"></span>
{{ subtypeNames[subtypeId] || `Type ${subtypeId}` }}
</span>
</template>
@@ -64,7 +64,7 @@
:key="assetType"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
<span class="legend-dot" :style="legendDotStyle(color)"></span>
{{ assetTypeLabels[assetType] || assetType }}
</span>
</template>
@@ -240,6 +240,13 @@ const visibleAssetTypes = computed(() => {
return result
})
// Legend swatches wear the same ring as the markers they stand for, so the key
// reads as a key. Without this the dot took a surface-colored border and a light
// color looked ringed in white next to its black-ringed marker.
function legendDotStyle(color) {
return { background: color, borderColor: markerRingColor(props.theme) }
}
// Get visible subtypes when a type is selected
const visibleSubtypes = computed(() => {
if (!props.selectedAssetType) return {}
@@ -477,9 +484,9 @@ function renderMarkers() {
const marker = L.circleMarker([leafletY, leafletX], {
radius: 6,
fillColor: color,
// Ring picked from the fill, not fixed white - a white ring on a light
// marker vanished against the light (white) blueprint.
color: markerRingColor(color),
// Ring keyed on the theme's surface: dark on the white blueprint, light on
// the dark one. A fixed white ring vanished against the light blueprint.
color: markerRingColor(props.theme),
weight: 2,
fillOpacity: 1,
renderer: canvasRenderer
@@ -630,6 +637,8 @@ watch(() => props.machines, (newVal, oldVal) => {
watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) {
imageOverlay.setUrl(blueprintUrlFor(newTheme))
// Marker rings are keyed on the surface, so they have to be redrawn too.
renderMarkers()
}
})

View File

@@ -53,19 +53,17 @@ 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'
// Ring color for a marker, keyed on the SURFACE it sits on, not on its fill.
// The ring's job is to separate the marker from the background, so every marker
// in a theme wears the same one - keying it off the fill instead singled out the
// light colors with a black ring while their neighbours kept white ones.
//
// A ring close to its own fill is never a problem: a fill that resembles the
// ring is by definition far from the background, which is what carries the
// separation. So a dark ring on white and a light ring on the dark card work for
// every fill, including a washed-out color a user picked by hand for a subtype.
export function markerRingColor(theme) {
return theme === 'light' ? '#1b1b1b' : '#ffffff'
}
// Resolve the marker color for an asset the same way ShopFloorMap does: when a

View File

@@ -58,20 +58,21 @@ describe('marker legibility', () => {
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('rings by surface, not by fill', () => {
// Uniform within a theme: every marker on the white blueprint wears the
// dark ring, every marker on the dark one wears the light ring. Keying it
// off the fill singled out the light colors with a black ring.
expect(markerRingColor('light')).toBe('#1b1b1b')
expect(markerRingColor('dark')).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('gives each ring 3:1 against the surface it sits on', () => {
expect(contrast(markerRingColor('light'), WHITE)).toBeGreaterThanOrEqual(3)
expect(contrast(markerRingColor('dark'), DARK_CARD)).toBeGreaterThanOrEqual(3)
})
it('falls back to a light ring for a malformed color', () => {
expect(markerRingColor('')).toBe('#ffffff')
expect(markerRingColor('nonsense')).toBe('#ffffff')
it('defaults to the light ring when the theme is missing', () => {
expect(markerRingColor(undefined)).toBe('#ffffff')
})
})

View File

@@ -97,8 +97,8 @@ 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)
// Outline the swatch against the white page, same rule as the markers.
doc.setDrawColor(markerRingColor(entry.color)); doc.setLineWidth(0.4)
// The page is always white, so the ring is the light-surface one throughout.
doc.setDrawColor(markerRingColor('light')); doc.setLineWidth(0.4)
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'FD')
doc.setTextColor('#333333')
doc.text(label, lx + swatch + 4, ly)
@@ -131,8 +131,8 @@ export async function exportMapPdf(opts) {
const fill = resolveMarkerColor(a, { selectedType, subtypeColors })
const [r, g, b] = hexToRgb(fill)
doc.setFillColor(r, g, b)
// The PDF prints on white, where a white ring is invisible - ring by fill.
doc.setDrawColor(markerRingColor(fill)); doc.setLineWidth(0.6)
// Prints on white, where a white ring is invisible - use the dark ring.
doc.setDrawColor(markerRingColor('light')); doc.setLineWidth(0.6)
doc.circle(x, y, radius, 'FD')
}