// Plant markers are a solid circle in the plant's color with a 1–2 letter
// monogram — the design's replacement for emoji icons. The letters are derived
// from the name, never stored, so a renamed plant re-letters itself.
//
// Rule: the initial of the species (the part before a " — Variety" suffix).
// Within one catalog, plants that share an initial are told apart by a second,
// lowercase letter — Marigold → Ma, Mint → Mi. The bare initial goes to the
// plant that entered the catalog first (lowest id): the built-ins are seeded
// roughly commonest-first, so Tomato keeps T over Thyme, and a variety you add
// later ("Tomato — Cherokee Purple") gets To rather than displacing it. The
// collision set is the whole catalog, so a plant's letters are the same on
// every screen.
/** "Tomato — Cherokee Purple" → "Tomato"; "Melon (Hale's Best)" → "Melon". */
export function speciesName(name: string): string {
const cut = name.split(/\s[—–-]\s|\s\(/)[0] ?? name
return cut.trim() || name.trim()
}
function letters(name: string): string[] {
// Letters only; a leading digit or punctuation would make an unreadable mark.
return speciesName(name)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^A-Za-z]/g, '')
.split('')
}
/** The monogram for every plant in the catalog, keyed by id. */
export function monogramMap
(plants: readonly P[]): Map {
const out = new Map()
const taken = new Set()
const sorted = [...plants].sort((a, b) => a.id - b.id || a.name.localeCompare(b.name))
for (const p of sorted) {
const ls = letters(p.name)
if (ls.length === 0) {
out.set(p.id, '?')
continue
}
const initial = ls[0].toUpperCase()
// Prefer the bare initial, then initial + each following letter in turn —
// skipping a repeat of the initial ("Pp" is not a mark anyone can read) —
// then give up on uniqueness rather than grow past two letters.
const candidates = [
initial,
...ls
.slice(1)
.filter((l) => l.toUpperCase() !== initial)
.map((l) => initial + l.toLowerCase()),
]
const pick = candidates.find((c) => !taken.has(c)) ?? candidates[candidates.length - 1]
taken.add(pick)
out.set(p.id, pick)
}
return out
}
/** A single plant's monogram when the catalog isn't at hand (a fallback, not the
* collision-aware mark — prefer monogramMap where a list exists). */
export function monogramFor(name: string): string {
const ls = letters(name)
return ls.length ? ls[0].toUpperCase() : '?'
}
// --- Lettering color --------------------------------------------------------
// Letters are paper on the marker's color — which reads on tomato red and sage,
// and vanishes on garlic's #d9d2c5. Pale markers take the dark marker ink
// instead. Both inks are theme-stable (the marker's own color is), so the
// choice depends only on the color, never on light/dark mode.
const PAPER = 'var(--color-paper)'
const INK = 'var(--color-marker-ink)'
// Below this relative luminance, paper still clears ~2.5:1 against the marker;
// above it, the dark ink does better. Sage (#7a8a5e, 0.23) keeps paper; cabbage
// green (#8bc98b, 0.49), marigold orange and garlic flip to ink.
const PAPER_MAX_LUMINANCE = 0.37
/** WCAG relative luminance of a #rgb / #rrggbb color; null if unparseable. */
function luminance(color: string): number | null {
const m = color.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i)
if (!m) return null
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]
const channel = (i: number) => {
const v = parseInt(hex.slice(i, i + 2), 16) / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
/** The CSS color the monogram letters take on a marker of `color`. */
export function monogramInk(color: string): string {
const l = luminance(color)
return l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER
}