refactor: replace hash-based license badge coloring with a deterministic, collision-resistant palette based on sort order

This commit is contained in:
estifanos
2026-08-28 11:39:47 +00:00
parent c4b903bc49
commit 3b0e5b1eb4

View File

@@ -49,32 +49,62 @@ const SEARCH_DEBOUNCE_MS = 300;
/**
* Badge colours for licence types.
*
* Red and yellow are left out on purpose: they read as a problem, and a
* licence type is not one. Green is out too — it means verified elsewhere in
* the backoffice.
* Red is left out: it reads as a problem, and a licence type is not one.
* Everything else the theme offers is in, because the point of the colour is
* telling two licence types apart at a glance.
*/
const SCOPE_COLORS = [
'grape',
'violet',
'indigo',
'cyan',
'teal',
'pink',
'orange',
'blue',
'grape',
'teal',
'orange',
'violet',
'cyan',
'pink',
'lime',
'indigo',
'green',
'yellow',
'gray',
];
/** Doubles the palette: the same hue, a visibly different badge. */
const SCOPE_VARIANTS = ['light', 'outline'] as const;
/**
* The same licence type keeps the same colour on every row and every visit —
* derived from its id rather than its position, so a colour is something the
* eye can learn instead of a lottery per render.
* A colour per licence type, assigned by position in the catalogue.
*
* Hashing the id looked tidier and was wrong: eight buckets over sixteen
* licence types collide by the pigeonhole principle, so Vessel Registration
* and Freight Forwarder came out the same colour and the badge stopped
* carrying information. Walking the sorted catalogue instead gives every type
* a distinct colour until the palette runs out, and only then repeats a hue in
* the other variant — 24 distinct badges before any two can look alike.
*
* Sorted by `sortOrder` so the assignment is the same for every officer and
* survives a refresh; a type added later takes the next free style rather than
* reshuffling the ones already learned.
*/
function scopeColor(licenseTypeId: string): string {
let hash = 0;
for (let i = 0; i < licenseTypeId.length; i += 1) {
hash = (hash * 31 + licenseTypeId.charCodeAt(i)) % 997;
}
return SCOPE_COLORS[hash % SCOPE_COLORS.length];
function buildScopeStyles(
types: { id: string; sortOrder: number }[],
): Map<string, { color: string; variant: (typeof SCOPE_VARIANTS)[number] }> {
const styles = new Map<
string,
{ color: string; variant: (typeof SCOPE_VARIANTS)[number] }
>();
types
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.forEach((type, index) => {
styles.set(type.id, {
color: SCOPE_COLORS[index % SCOPE_COLORS.length],
variant:
SCOPE_VARIANTS[
Math.floor(index / SCOPE_COLORS.length) % SCOPE_VARIANTS.length
],
});
});
return styles;
}
/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */
@@ -173,6 +203,11 @@ export function PersonalDocumentsCard() {
setPageIndex(0);
}
const scopeStyles = useMemo(
() => buildScopeStyles(licenseTypes?.items ?? []),
[licenseTypes],
);
const typeName = (id: string) => {
const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id);
return found ? localized(found.name) || found.key : id;
@@ -206,11 +241,15 @@ export function PersonalDocumentsCard() {
</Badge>
) : (
<Group gap={4}>
{row.original.scope.map((id) => (
<Badge key={id} size="sm" variant="light" color={scopeColor(id)}>
{typeName(id)}
</Badge>
))}
{row.original.scope.map((id) => {
// A type the catalogue no longer lists still needs a badge.
const style = scopeStyles.get(id) ?? { color: 'gray', variant: 'light' };
return (
<Badge key={id} size="sm" variant={style.variant} color={style.color}>
{typeName(id)}
</Badge>
);
})}
</Group>
),
},
@@ -274,9 +313,9 @@ export function PersonalDocumentsCard() {
),
},
],
// `typeName` closes over the licence-type list, which `localized` also reads.
// `typeName` and `scopeStyles` both close over the licence-type list.
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, localized, licenseTypes],
[t, localized, licenseTypes, scopeStyles],
);
/**