// 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 }